From abe9e4062ee51d58bfd0f4d14f7403adf8b45049 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 13:58:34 +0100 Subject: [PATCH 01/16] Validate source-bound organization fallback fixtures --- .github/linters/.markdown-lint.yml | 29 +++ .../linters/.powershell-psscriptanalyzer.psd1 | 55 +++++ .github/scripts/Invoke-PesterSuite.ps1 | 220 +++++++++++++++++ .github/scripts/PullRequestValidation.psm1 | 193 +++++++++++++++ .github/tests/PullRequestValidation.Tests.ps1 | 141 +++++++++++ .../tests/fixtures/pull_request_template.md | 226 ++++++++++++++++++ .github/validation.psd1 | 6 + 7 files changed, 870 insertions(+) create mode 100644 .github/linters/.markdown-lint.yml create mode 100644 .github/linters/.powershell-psscriptanalyzer.psd1 create mode 100644 .github/scripts/Invoke-PesterSuite.ps1 create mode 100644 .github/scripts/PullRequestValidation.psm1 create mode 100644 .github/tests/PullRequestValidation.Tests.ps1 create mode 100644 .github/tests/fixtures/pull_request_template.md create mode 100644 .github/validation.psd1 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/scripts/Invoke-PesterSuite.ps1 b/.github/scripts/Invoke-PesterSuite.ps1 new file mode 100644 index 0000000..dd8f903 --- /dev/null +++ b/.github/scripts/Invoke-PesterSuite.ps1 @@ -0,0 +1,220 @@ +#!/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 — a run that silently discovers nothing is green and + worthless, so it is treated as a failure. It exits 0 only when every discovered test + passed, which is what makes it usable as a merge gate. + + 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 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 (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 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 ($Result.Result -eq 'Passed') { '✅' } 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.Result -eq 'Passed' -and $failedSuite -eq 0) { + 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..b42cdb1 --- /dev/null +++ b/.github/scripts/PullRequestValidation.psm1 @@ -0,0 +1,193 @@ +#Requires -Version 7.4 + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Test-PullRequestTemplate { + <# + .SYNOPSIS + Validate a complete organization fallback against its immutable source. + + .DESCRIPTION + Check the announcement boundary, classifier order, scoped supporting pairs, + final conditional decision fields, and complete source correspondence. + Only HTML authoring comments may precede the recorded scaffold. + + .EXAMPLE + Test-PullRequestTemplate -Content $template + Return structural counts and the verified source revision. + + .INPUTS + None + + You can't pipe objects to Test-PullRequestTemplate. + + .OUTPUTS + System.Management.Automation.PSCustomObject + + The verified source revision, digest, and structural counts. + #> + [OutputType([pscustomobject])] + [CmdletBinding()] + param( + # Complete template text, not a filled-in pull request description. + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $Content + ) + + $content = ($Content -replace '\r\n?', "`n").TrimStart([char] 0xfeff) + if ([string]::IsNullOrWhiteSpace($content)) { + throw 'The fallback template is empty.' + } + + $marker = '' + if ([regex]::Matches($content, [regex]::Escape($marker)).Count -ne 1 -or + [regex]::Matches($content, '(?m)^$').Count -ne 1) { + throw 'The fallback must contain exactly one standalone announcement marker.' + } + + $starts = [regex]::Matches($content, '(?m)^\{\{ One or two short summary paragraphs') + if ($starts.Count -ne 1) { + throw 'The complete scaffold is missing or ambiguous; restore the pinned source.' + } + $prefix = $content.Substring(0, $starts[0].Index) -replace '(?s)', '' + if (-not [string]::IsNullOrWhiteSpace($prefix)) { + throw 'The scaffold prefix may contain only complete HTML authoring comments.' + } + $body = $content.Substring($starts[0].Index).TrimEnd() + "`n" + + $classifiers = [regex]::Matches($body, '(?m)^## (?Breaking|Removed|New|Changed|Fixed):') + $names = @($classifiers | ForEach-Object { $_.Groups['name'].Value }) + if (($names -join ',') -cne 'Breaking,Removed,New,Changed,Fixed') { + throw 'The complete scaffold requires five ordered classifier groups: Breaking, Removed, New, Changed, Fixed.' + } + + $release = [regex]::Matches($body, '(?s)
\nRelease decisions.*?
') + if ($release.Count -ne 1 -or $release[0].Index -lt $classifiers[4].Index -or + $body.Substring($release[0].Index).TrimEnd() -cne $release[0].Value) { + throw 'The complete scaffold requires one final release-decision block after all result groups.' + } + + $sections = [regex]::Matches( + $body, + '(?ms)^## (?:Breaking|Removed|New|Changed|Fixed):.*?(?=^## |
\nRelease decisions|\z)' + ) + foreach ($section in $sections) { + $technical = [regex]::Matches($section.Value, '(?s)
\nTechnical details.*?
') + $references = [regex]::Matches($section.Value, '(?s)
\nRelated references.*?
') + if ($technical.Count -ne 1 -or $references.Count -ne 1 -or + $technical[0].Index -gt $references[0].Index -or + [regex]::Matches($section.Value, '
').Count -ne 2) { + throw 'Every classifier requires one scoped Technical details block followed by one scoped Related references block.' + } + } + + $fields = [regex]::Matches($release[0].Value, '(?m)^- \*\*(?Release|Version bump|Mode|Labels):\*\*') + $fieldNames = @($fields | ForEach-Object { $_.Groups['name'].Value }) + if (($fieldNames -join ',') -cne 'Release,Version bump,Mode,Labels') { + throw 'The final release-decision fields must be Release, Version bump, Mode, and Labels, in order.' + } + + $sourcePath = Join-Path -Path $PSScriptRoot -ChildPath '..\validation.psd1' + $source = Import-PowerShellDataFile -LiteralPath $sourcePath + $digest = [Convert]::ToHexString( + [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($body)) + ).ToLowerInvariant() + if ($digest -cne $source.TemplateSha256) { + throw "The complete scaffold differs from the pinned source $($source.SourceRepository)@$($source.SourceCommit)." + } + + [pscustomobject]@{ + SourceCommit = $source.SourceCommit + TemplateSha256 = $digest + AnnouncementMarkers = 1 + Classifiers = $classifiers.Count + ScopedDetails = $sections.Count + ReleaseDecisionBlocks = $release.Count + } +} + +function Get-ValidationInput { + <# + .SYNOPSIS + Identify the actual committed inputs changed by a pull request. + + .DESCRIPTION + Compare the merge base with HEAD, retain deleted templates for explicit failure, + 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 changed input, Markdown, and template 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.' + } + $changed = git -C $repository diff --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 '[\r\n]' }).Count -gt 0) { + throw 'Changed filenames containing line breaks are unsupported; rename those files before validation.' + } + $relevant = @($paths | Where-Object { $_ -match '\.(md|ps1|psm1|psd1|yml|yaml)$' }) + $templates = @($relevant | Where-Object { + $_ -match '^\.github/(pull_request_template\.md|PULL_REQUEST_TEMPLATE/[^/]+\.md)$' -or + $_ -eq '.github/tests/fixtures/pull_request_template.md' + }) + $existing = @($relevant | Where-Object { + Test-Path -LiteralPath (Join-Path -Path $repository -ChildPath $_) -PathType Leaf + }) + if ($existing.Count -eq 0 -and $templates.Count -eq 0) { + throw 'No applicable changed input was found. Fixture self-tests are not a complete pull request validation pass.' + } + + [pscustomobject]@{ + HeadCommit = $headCommit + MergeBase = $mergeBase + ChangedFiles = $relevant + ExistingFiles = $existing + MarkdownFiles = @($existing | Where-Object { $_ -match '\.md$' }) + TemplateFiles = $templates + } +} + +Export-ModuleMember -Function Test-PullRequestTemplate, Get-ValidationInput diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 new file mode 100644 index 0000000..3ab8caf --- /dev/null +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -0,0 +1,141 @@ +#Requires -Version 7.4 +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester discovers and executes setup and test blocks in separate scopes.' +)] +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +BeforeDiscovery { + $fixturePath = Join-Path -Path $PSScriptRoot -ChildPath 'fixtures\pull_request_template.md' + $fixture = [IO.File]::ReadAllText($fixturePath) -replace '\r\n?', "`n" + $marker = '' + $technical = [regex]::Match($fixture, '(?s)
\nTechnical details.*?
').Value + $references = [regex]::Match($fixture, '(?s)
\nRelated references.*?
').Value + $release = [regex]::Match($fixture, '(?s)
\nRelease decisions.*?
').Value + $malformed = @( + @{ Name = 'missing announcement marker'; Content = $fixture.Replace($marker, ''); ExpectedError = '*announcement marker*' } + @{ Name = 'duplicate announcement marker'; Content = $fixture.Replace($marker, "$marker`n$marker"); ExpectedError = '*announcement marker*' } + @{ Name = 'fenced announcement marker'; Content = $fixture.Replace($marker, "``````markdown`n$marker`n``````"); ExpectedError = '*pinned source*' } + @{ Name = 'marker after a result'; Content = $fixture.Replace("$marker`n", '').Replace('## Removed:', "$marker`n## Removed:"); ExpectedError = '*pinned source*' } + @{ Name = 'missing classifier'; Content = $fixture.Replace('## Removed:', '## Other:'); ExpectedError = '*classifier*' } + @{ Name = 'unordered classifiers'; Content = $fixture.Replace('## Breaking:', '## Swap:').Replace('## New:', '## Breaking:').Replace('## Swap:', '## New:'); ExpectedError = '*classifier*' } + @{ Name = 'missing scoped evidence'; Content = $fixture.Replace($technical, ''); ExpectedError = '*scoped*' } + @{ Name = 'missing scoped references'; Content = $fixture.Replace($references, ''); ExpectedError = '*scoped*' } + @{ Name = 'references before evidence'; Content = $fixture.Replace("$technical`n`n$references", "$references`n`n$technical"); ExpectedError = '*scoped*' } + @{ Name = 'global evidence'; Content = $technical + "`n" + $fixture.Replace($technical, ''); ExpectedError = '*prefix*' } + @{ Name = 'missing final decision'; Content = $fixture.Replace($release, ''); ExpectedError = '*final release-decision*' } + @{ Name = 'decision before results'; Content = $fixture.Replace($release, '').Replace($marker, "$marker`n$release"); ExpectedError = '*final release-decision*' } + @{ Name = 'duplicate decision'; Content = $fixture + $release; ExpectedError = '*final release-decision*' } + @{ Name = 'missing resolved bump'; Content = $fixture.Replace('- **Version bump:**', '- **Other:**'); ExpectedError = '*release-decision fields*' } + @{ Name = 'missing conditional no-release guidance'; Content = $fixture.Replace("Delete this field when Release`n is No.", ''); ExpectedError = '*pinned source*' } + @{ Name = 'missing adoption instructions'; Content = $fixture.Replace('adoption actions or an explicit no-action result.', 'See the diff for instructions.'); ExpectedError = '*pinned source*' } + @{ Name = 'unresolved source drift'; Content = $fixture.Replace('documented shared default', 'patch-only AutoPatching'); ExpectedError = '*pinned source*' } + @{ Name = 'non-comment prefix'; Content = "# Unrelated heading`n$fixture"; ExpectedError = '*prefix*' } + ) +} + +BeforeAll { + $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\scripts\PullRequestValidation.psm1' + Import-Module -Name $modulePath -Force + $fixturePath = Join-Path -Path $PSScriptRoot -ChildPath 'fixtures\pull_request_template.md' + $valid = [IO.File]::ReadAllText($fixturePath) -replace '\r\n?', "`n" +} + +Describe 'Pinned complete fallback scaffold' { + It 'accepts the independently recorded source fixture' { + $result = Test-PullRequestTemplate -Content $valid + $result.TemplateSha256 | Should -BeExactly '94fd209828f902b5faf4af9248826078843ea1ce37f168049606b53cfb6802c4' + $result.AnnouncementMarkers | Should -Be 1 + $result.Classifiers | Should -Be 5 + $result.ScopedDetails | Should -Be 5 + $result.ReleaseDecisionBlocks | Should -Be 1 + } + + It 'accepts a comment-only authoring wrapper and CRLF line endings' { + $content = "`n" + $valid + $result = Test-PullRequestTemplate -Content ($content -replace "`n", "`r`n") + $result.Classifiers | Should -Be 5 + } + + It 'rejects ' -ForEach $malformed { + { Test-PullRequestTemplate -Content $Content } | Should -Throw -ExpectedMessage $ExpectedError + } + + It 'rejects empty content rather than returning a pass' { + { Test-PullRequestTemplate -Content '' } | Should -Throw -ExpectedMessage '*empty*' + } + + It 'does not identify the unchanged legacy fallback as the candidate' { + $legacy = "`n---`n
Technical details
" + { Test-PullRequestTemplate -Content $legacy } | Should -Throw -ExpectedMessage '*announcement marker*' + } +} + +Describe 'Actual committed validation inputs' { + BeforeEach { + $PSNativeCommandUseErrorActionPreference = $true + $repositoryPath = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString()) + $null = New-Item -ItemType Directory -Path $repositoryPath + git -C $repositoryPath init --quiet --initial-branch=main + git -C $repositoryPath config user.name 'Validation fixture' + git -C $repositoryPath config user.email 'fixture@example.invalid' + git -C $repositoryPath config core.autocrlf false + [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'base.md'), "# Base`n") + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Record anonymous base fixture' + $baseRevision = git -C $repositoryPath rev-parse HEAD + if ($LASTEXITCODE -ne 0) { + throw 'Could not create the anonymous Git fixture.' + } + } + + It 'rejects an empty diff instead of counting self-tests as changed inputs' { + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | + Should -Throw -ExpectedMessage '*No applicable changed input*' + } + + It 'rejects a media-only diff as no applicable input' { + [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'image.png'), 'anonymous fixture') + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Add anonymous non-Markdown input' + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | + Should -Throw -ExpectedMessage '*No applicable changed input*' + } + + It 'selects committed changed Markdown with spaces but not unchanged Markdown' { + [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'changed file.md'), "# Changed`n") + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Add changed Markdown input' + $result = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision + $result.MarkdownFiles | Should -HaveCount 1 + $result.MarkdownFiles[0] | Should -BeExactly 'changed file.md' + $result.TemplateFiles | Should -HaveCount 0 + } + + It 'retains a deleted fallback as an applicable structural error' { + $directory = Join-Path -Path $repositoryPath -ChildPath '.github' + $null = New-Item -ItemType Directory -Path $directory + $templatePath = Join-Path -Path $directory -ChildPath 'pull_request_template.md' + [IO.File]::WriteAllText($templatePath, $valid) + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Record the source-aligned fallback' + $baseRevision = git -C $repositoryPath rev-parse HEAD + Remove-Item -LiteralPath $templatePath + [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'changed.md'), "# Changed`n") + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Remove the fallback while changing Markdown' + $result = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision + $result.TemplateFiles | Should -Contain '.github/pull_request_template.md' + $result.MarkdownFiles | Should -Not -Contain '.github/pull_request_template.md' + } + + It 'fails explicitly for an invalid base revision' { + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision 'missing-reference' } | + Should -Throw -ExpectedMessage '*base revision*' + } +} diff --git a/.github/tests/fixtures/pull_request_template.md b/.github/tests/fixtures/pull_request_template.md new file mode 100644 index 0000000..25a3802 --- /dev/null +++ b/.github/tests/fixtures/pull_request_template.md @@ -0,0 +1,226 @@ +{{ One or two short summary paragraphs that state what changes, why, who is +affected, and required adoption actions or an explicit no-action result. }} + + + +## Breaking: {{ Reader-facing result }} + +{{ Explain what changes, why, affected supported uses, compatibility, and exact +adoption actions or an explicit no-action result. Include applicable +prerequisites, verification, ordering, and any required intermediate installation +or run. }} + +{{ Interface or old-to-new example, comparison, or diagram after the prose when +needed to make adoption unambiguous. Keep required transition details in this +note; link to evergreen usage docs only for optional depth. Delete when prose +is sufficient. }} + +
+Technical details + +{{ Implementation and validation evidence, closing delivery-leaf plan progress, +and issue-convergence search scope and outcome for this result, even when no +additional issue converges. }} + +| Changed surface | Standards checked | Framework docs checked | Result | +| --- | --- | --- | --- | +| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | + +{{ For an applicable release-managed result, identify the consumer-change release +and source baseline and evidence supporting the actions or no-action result +above. For an affected integration template, identify its repository, verified +compatible immutable commit, and compatibility evidence. Publication resolves +actual release coordinates; do not predict a final version. Delete inapplicable +evidence. }} + +
+ +
+Related references + +- Resolves MSXOrg/repository#123 +- References MSXOrg/repository#456 +- Depends on MSXOrg/other-repository#789 + +
+ +## Removed: {{ Reader-facing result }} + +{{ Explain what changes, why, affected supported uses, compatibility, and exact +adoption actions or an explicit no-action result. Include applicable +prerequisites, verification, ordering, and any required intermediate installation +or run. }} + +{{ Interface or old-to-new example, comparison, or diagram after the prose when +needed to make adoption unambiguous. Keep required transition details in this +note; link to evergreen usage docs only for optional depth. Delete when prose +is sufficient. }} + +
+Technical details + +{{ Implementation and validation evidence, closing delivery-leaf plan progress, +and issue-convergence search scope and outcome for this result, even when no +additional issue converges. }} + +| Changed surface | Standards checked | Framework docs checked | Result | +| --- | --- | --- | --- | +| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | + +{{ For an applicable release-managed result, identify the consumer-change release +and source baseline and evidence supporting the actions or no-action result +above. For an affected integration template, identify its repository, verified +compatible immutable commit, and compatibility evidence. Publication resolves +actual release coordinates; do not predict a final version. Delete inapplicable +evidence. }} + +
+ +
+Related references + +- Resolves MSXOrg/repository#123 +- References MSXOrg/repository#456 +- Depends on MSXOrg/other-repository#789 + +
+ +## New: {{ Reader-facing result }} + +{{ Explain what changes, why, affected supported uses, compatibility, and exact +adoption actions or an explicit no-action result. Include applicable +prerequisites, verification, ordering, and any required intermediate installation +or run. }} + +{{ Interface or old-to-new example, comparison, or diagram after the prose when +needed to make adoption unambiguous. Keep required transition details in this +note; link to evergreen usage docs only for optional depth. Delete when prose +is sufficient. }} + +
+Technical details + +{{ Implementation and validation evidence, closing delivery-leaf plan progress, +and issue-convergence search scope and outcome for this result, even when no +additional issue converges. }} + +| Changed surface | Standards checked | Framework docs checked | Result | +| --- | --- | --- | --- | +| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | + +{{ For an applicable release-managed result, identify the consumer-change release +and source baseline and evidence supporting the actions or no-action result +above. For an affected integration template, identify its repository, verified +compatible immutable commit, and compatibility evidence. Publication resolves +actual release coordinates; do not predict a final version. Delete inapplicable +evidence. }} + +
+ +
+Related references + +- Resolves MSXOrg/repository#123 +- References MSXOrg/repository#456 +- Depends on MSXOrg/other-repository#789 + +
+ +## Changed: {{ Reader-facing result }} + +{{ Explain what changes, why, affected supported uses, compatibility, and exact +adoption actions or an explicit no-action result. Include applicable +prerequisites, verification, ordering, and any required intermediate installation +or run. }} + +{{ Interface or old-to-new example, comparison, or diagram after the prose when +needed to make adoption unambiguous. Keep required transition details in this +note; link to evergreen usage docs only for optional depth. Delete when prose +is sufficient. }} + +
+Technical details + +{{ Implementation and validation evidence, closing delivery-leaf plan progress, +and issue-convergence search scope and outcome for this result, even when no +additional issue converges. }} + +| Changed surface | Standards checked | Framework docs checked | Result | +| --- | --- | --- | --- | +| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | + +{{ For an applicable release-managed result, identify the consumer-change release +and source baseline and evidence supporting the actions or no-action result +above. For an affected integration template, identify its repository, verified +compatible immutable commit, and compatibility evidence. Publication resolves +actual release coordinates; do not predict a final version. Delete inapplicable +evidence. }} + +
+ +
+Related references + +- Resolves MSXOrg/repository#123 +- References MSXOrg/repository#456 +- Depends on MSXOrg/other-repository#789 + +
+ +## Fixed: {{ Reader-facing result }} + +{{ Explain what changes, why, affected supported uses, compatibility, and exact +adoption actions or an explicit no-action result. Include applicable +prerequisites, verification, ordering, and any required intermediate installation +or run. }} + +{{ Interface or old-to-new example, comparison, or diagram after the prose when +needed to make adoption unambiguous. Keep required transition details in this +note; link to evergreen usage docs only for optional depth. Delete when prose +is sufficient. }} + +
+Technical details + +{{ Implementation and validation evidence, closing delivery-leaf plan progress, +and issue-convergence search scope and outcome for this result, even when no +additional issue converges. }} + +| Changed surface | Standards checked | Framework docs checked | Result | +| --- | --- | --- | --- | +| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | + +{{ For an applicable release-managed result, identify the consumer-change release +and source baseline and evidence supporting the actions or no-action result +above. For an affected integration template, identify its repository, verified +compatible immutable commit, and compatibility evidence. Publication resolves +actual release coordinates; do not predict a final version. Delete inapplicable +evidence. }} + +
+ +
+Related references + +- Resolves MSXOrg/repository#123 +- References MSXOrg/repository#456 +- Depends on MSXOrg/other-repository#789 + +
+ +
+Release decisions + +- **Release:** {{ Yes or No }} - {{ Evidence from the artifact, route, and + supported consumer contract. }} +- **Version bump:** {{ Patch, Minor, or Major }} - {{ Compatibility evidence. + Identify the explicit label, supported caller input, repository setting, or + documented shared default that supplies it. Delete this field when Release + is No. }} +- **Mode:** {{ Stable or Prerelease }} - {{ Target-route or override evidence. + Identify configured release-candidate routing when applicable. Delete this + field when Release is No. }} +- **Labels:** {{ Exact `release:*` labels, or `None` when effective configuration + supplies the decision without an override label. }} + +
diff --git a/.github/validation.psd1 b/.github/validation.psd1 new file mode 100644 index 0000000..950fd27 --- /dev/null +++ b/.github/validation.psd1 @@ -0,0 +1,6 @@ +@{ + SourceRepository = 'MSXOrg/docs' + SourceCommit = '1ececb2bd9f1680c87cec15a45ac927a554fce9c' + SourcePath = 'src/docs/Ways-of-Working/PR-Format.md' + TemplateSha256 = '94fd209828f902b5faf4af9248826078843ea1ce37f168049606b53cfb6802c4' +} From 7b478f8f77e64a9eab7dca912ef805f53d65564c Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 14:18:51 +0100 Subject: [PATCH 02/16] Run read-only validation for pull request changes --- .github/VALIDATION.md | 111 ++++++++++++++++++ .github/dependabot.yml | 8 ++ .github/linters/.yaml-lint.yml | 59 ++++++++++ .github/linters/zizmor.yml | 4 + .github/scripts/Test-OrganizationDefaults.ps1 | 108 +++++++++++++++++ .../Organization-Defaults-Validation.yml | 60 ++++++++++ 6 files changed, 350 insertions(+) create mode 100644 .github/VALIDATION.md create mode 100644 .github/dependabot.yml create mode 100644 .github/linters/.yaml-lint.yml create mode 100644 .github/linters/zizmor.yml create mode 100644 .github/scripts/Test-OrganizationDefaults.ps1 create mode 100644 .github/workflows/Organization-Defaults-Validation.yml diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md new file mode 100644 index 0000000..a0467f7 --- /dev/null +++ b/.github/VALIDATION.md @@ -0,0 +1,111 @@ +--- +title: Validate organization-default pull requests +description: Check changed files and source-bound fallback scaffolds locally. +--- + +# Validate organization-default pull requests + +Run the same fixture and changed-input checks used by the read-only +`Organization defaults validation` job. + +## Before starting + +- Use PowerShell 7.4 or later, Git, and Node.js/npm. +- For local code/configuration lint, also provide PSScriptAnalyzer, yamllint, + actionlint, and zizmor. +- Fetch the intended base and commit the changes being checked. The diff command + examines commits, not staged or untracked files. +- Allow dependency restoration when needed: the source-native runner pins + Pester 6.1.0 and verifies its module GUID. Markdown examples pin + markdownlint-cli2 0.24.0. + +## Steps + +1. Fetch the base: `git fetch origin main`. +2. Run the valid, malformed, and changed-input fixture suite: + + ```powershell + pwsh -NoProfile -File .github\scripts\Invoke-PesterSuite.ps1 -Path .github\tests -RequiredVersion 6.1.0 + ``` + +3. Validate the committed inputs and any changed complete fallback: + + ```powershell + pwsh -NoProfile -File .github\scripts\Test-OrganizationDefaults.ps1 -BaseRevision origin/main + ``` + +4. Lint the actual changed Markdown with the shared configuration: + + ```powershell + $ErrorActionPreference = 'Stop' + Import-Module -Name (Resolve-Path -LiteralPath '.github\scripts\PullRequestValidation.psm1').ProviderPath -Force + $inputs = Get-ValidationInput -RepositoryPath $PWD.ProviderPath -BaseRevision 'origin/main' + if ($inputs.MarkdownFiles.Count -eq 0) { + throw 'No changed Markdown: this command cannot report a Markdown pass.' + } + $npx = 'npx' + if ($IsWindows) { + $npx = 'npx.cmd' + } + $files = @($inputs.MarkdownFiles | ForEach-Object { ':' + $_ }) + & $npx --yes -- markdownlint-cli2@0.24.0 --config '.github\linters\.markdown-lint.yml' @files + if ($LASTEXITCODE -ne 0) { + throw 'Changed Markdown lint failed.' + } + ``` + +## Verify + +The fixture suite must discover tests, reject malformed scaffolds, and pass its +valid cases. The changed-input report identifies the actual head and counts +changed Markdown and production templates separately from its isolated fixture. +Empty/unsupported-only diffs fail explicitly. Unchanged fallback content is not +certified as the candidate. A changed missing or source-divergent fallback fails. + +The hosted job checks out the pull request head, compares its merge base, and +passes an exact changed-file filter to the shared linter. Only Markdown, +PowerShell, YAML, and workflow correctness/security checks are enabled. There +is no path trigger filter, write token, publication, or label mutation. + +The complete fixture and its LF-normalized SHA-256 in `validation.psd1` correspond +to the [immutable complete scaffold][scaffold], including all five classifier +groups, scoped supporting pairs, adoption/plan/convergence prompts, and conditional +final decision fields. This source snapshot is not a shipped producer runtime or +an installation dependency. The check validates template code, not filled-in PR +descriptions or a producer's effective release decision. + +The [shared Markdown configuration][markdown], [PowerShell configuration][powershell], +and [Pester runner][pester] are vendored from an immutable default-branch revision, +as is the [shared zizmor configuration][zizmor]. +The YAML configuration matches the [pinned shared linter baseline][yaml]. +Refresh a snapshot only against a selected immutable source, compare the complete +source content independently, and rerun the valid/malformed suite. + +For local code/configuration checks, use `Invoke-ScriptAnalyzer` with +`.github\linters\.powershell-psscriptanalyzer.psd1`, +`yamllint -c .github\linters\.yaml-lint.yml`, `actionlint`, and +`zizmor --offline -c .github\linters\zizmor.yml`. +Hosted checks use the pinned Super-Linter integration; +local fixture results do not replace a completed result at the actual PR head. + +GitHub-native Dependabot uses its [default three-day cooldown][cooldown]. +The shared zizmor configuration disables only the outdated missing-cooldown +audit; all other workflow audits remain enabled. No cooldown override or +label configuration is introduced. + +## If it fails + +| Symptom | Cause | Resolution | +| --- | --- | --- | +| No applicable changed input | Empty diff or unsupported-only files | Confirm the intended committed base/head; do not count fixture-only evidence as a changed-input pass. | +| Scaffold/source mismatch | Changed marker, groups, prompts, or decision structure | Restore the complete selected source scaffold and retain only comment-based authoring guidance before it. | +| Head mismatch or missing merge base | Wrong checkout or incomplete history | Fetch the intended base/head and rerun at the actual PR head. | +| Passing check does not hold merge | No required-check rule | Have the governance owner separately approve requiring `Organization defaults validation` from GitHub Actions for `main`. This code does not modify rulesets or access. | + +[scaffold]: https://github.com/MSXOrg/docs/blob/1ececb2bd9f1680c87cec15a45ac927a554fce9c/src/docs/Ways-of-Working/PR-Format.md#copy-the-complete-template +[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 +[pester]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/scripts/Invoke-PesterSuite.ps1 +[yaml]: https://github.com/super-linter/super-linter/blob/4ce20838b8ab83717e78138c5b3a1407148e0918/TEMPLATES/.yaml-lint.yml +[zizmor]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/linters/zizmor.yaml +[cooldown]: https://github.blog/changelog/2026-07-14-dependabot-version-updates-introduce-default-package-cooldown/ 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/.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/Test-OrganizationDefaults.ps1 b/.github/scripts/Test-OrganizationDefaults.ps1 new file mode 100644 index 0000000..96bb219 --- /dev/null +++ b/.github/scripts/Test-OrganizationDefaults.ps1 @@ -0,0 +1,108 @@ +#Requires -Version 7.4 + +<# + .SYNOPSIS + Validate actual changed inputs and the complete organization fallback. + + .DESCRIPTION + Check the actual head and changed templates, verify the isolated source fixture, + and report changed Markdown separately from unchanged fallback content. + 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 changed inputs and checked templates. +#> +[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 +} +foreach ($path in $inputs.MarkdownFiles) { + $content = [IO.File]::ReadAllText((Join-Path -Path $repository -ChildPath $path)) + if ([string]::IsNullOrWhiteSpace($content)) { + throw "Changed Markdown '$path' is empty; no Markdown content was validated." + } +} + +$fixturePath = Join-Path -Path $PSScriptRoot -ChildPath '..\tests\fixtures\pull_request_template.md' +$fixture = Test-PullRequestTemplate -Content ([IO.File]::ReadAllText($fixturePath)) +$productionTemplates = @($inputs.TemplateFiles | Where-Object { $_ -ne '.github/tests/fixtures/pull_request_template.md' }) +foreach ($path in $inputs.TemplateFiles) { + $templatePath = Join-Path -Path $repository -ChildPath $path + if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { + throw "Changed fallback '$path' is missing. Restore its complete source-aligned scaffold." + } + $null = Test-PullRequestTemplate -Content ([IO.File]::ReadAllText($templatePath)) + Write-Information -MessageData "Source-aligned changed template: $path" -InformationAction Continue +} +if ($productionTemplates.Count -eq 0) { + Write-Information -MessageData 'No production fallback changed: unchanged legacy content is not certified as the candidate.' -InformationAction Continue +} +if ($inputs.MarkdownFiles.Count -eq 0) { + Write-Information -MessageData 'No changed Markdown: only the applicable changed code/configuration is sent to its linters.' -InformationAction Continue +} + +$escapedPaths = @($inputs.ExistingFiles | ForEach-Object { [regex]::Escape($_) }) +$filter = '(^|/)(' + ($escapedPaths -join '|') + ')$' +if ($env:GITHUB_OUTPUT) { + [IO.File]::AppendAllText($env:GITHUB_OUTPUT, "files_regex=$filter`n") +} +$report = [pscustomobject]@{ + HeadCommit = $inputs.HeadCommit + ChangedInputs = $inputs.ExistingFiles.Count + ChangedMarkdown = $inputs.MarkdownFiles.Count + ChangedProductionTemplates = $productionTemplates.Count + SourceFixtureTemplates = 1 + SourceCommit = $fixture.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 | $($report.ChangedMarkdown) |" + "| Changed production templates structurally checked | $($report.ChangedProductionTemplates) |" + '| Isolated source fixtures checked (not unchanged fallback evidence) | 1 |' + "| Immutable fixture source | ``$($report.SourceCommit)`` |" + '' + 'Fixture checks do not certify unchanged legacy templates. Lint and Pester results follow separately.' + ) + [IO.File]::AppendAllText($env:GITHUB_STEP_SUMMARY, ($summary -join "`n") + "`n") +} +$report diff --git a/.github/workflows/Organization-Defaults-Validation.yml b/.github/workflows/Organization-Defaults-Validation.yml new file mode 100644 index 0000000..f54b0bd --- /dev/null +++ b/.github/workflows/Organization-Defaults-Validation.yml @@ -0,0 +1,60 @@ +--- +name: Organization defaults validation + +'on': + pull_request: + 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 valid and malformed fallback fixtures + shell: pwsh + run: >- + ./.github/scripts/Invoke-PesterSuite.ps1 + -Path ./.github/tests -RequiredVersion 6.1.0 + + - name: Lint the applicable changed files + # 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: + DEFAULT_BRANCH: main + MULTI_STATUS: false + USE_FIND_ALGORITHM: true + FILTER_REGEX_INCLUDE: ${{ steps.inputs.outputs.files_regex }} + VALIDATE_MARKDOWN: true + VALIDATE_POWERSHELL: true + VALIDATE_YAML: true + VALIDATE_GITHUB_ACTIONS: true + VALIDATE_GITHUB_ACTIONS_ZIZMOR: true + GITHUB_ACTIONS_ZIZMOR_CONFIG_FILE: zizmor.yml From e9b31ad38d961b874f67cb51c661fc0156bd3bc6 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 14:35:48 +0100 Subject: [PATCH 03/16] Retain fallback paths when templates are renamed --- .github/scripts/PullRequestValidation.psm1 | 3 ++- .github/tests/PullRequestValidation.Tests.ps1 | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/scripts/PullRequestValidation.psm1 b/.github/scripts/PullRequestValidation.psm1 index b42cdb1..ff8b786 100644 --- a/.github/scripts/PullRequestValidation.psm1 +++ b/.github/scripts/PullRequestValidation.psm1 @@ -160,7 +160,8 @@ function Get-ValidationInput { if ($LASTEXITCODE -ne 0) { throw 'Cannot find the merge base. Fetch the complete base and head history.' } - $changed = git -C $repository diff --name-only --diff-filter=ACDMRT -z $mergeBase $headCommit -- + # Retain the old path when a rename removes a GitHub template lookup file. + $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.' } diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index 3ab8caf..824bafe 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -134,6 +134,22 @@ Describe 'Actual committed validation inputs' { $result.MarkdownFiles | Should -Not -Contain '.github/pull_request_template.md' } + It 'retains the old path when a fallback is renamed outside template lookup' { + $directory = Join-Path -Path $repositoryPath -ChildPath '.github' + $null = New-Item -ItemType Directory -Path $directory + $templatePath = Join-Path -Path $directory -ChildPath 'pull_request_template.md' + [IO.File]::WriteAllText($templatePath, $valid) + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Record the source-aligned fallback' + $baseRevision = git -C $repositoryPath rev-parse HEAD + $renamedPath = Join-Path -Path $directory -ChildPath 'renamed.md' + git -C $repositoryPath mv -- $templatePath $renamedPath + git -C $repositoryPath commit --quiet -m 'Move the fallback outside template lookup' + $result = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision + $result.TemplateFiles | Should -Contain '.github/pull_request_template.md' + $result.MarkdownFiles | Should -Contain '.github/renamed.md' + } + It 'fails explicitly for an invalid base revision' { { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision 'missing-reference' } | Should -Throw -ExpectedMessage '*base revision*' From bf1a3bf5933a94af0eec8fd3953f1f0920f0cf57 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 14:35:48 +0100 Subject: [PATCH 04/16] Disable Super-Linter pull request comment writes --- .github/VALIDATION.md | 3 +++ .github/workflows/Organization-Defaults-Validation.yml | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index a0467f7..4c58075 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -66,6 +66,9 @@ The hosted job checks out the pull request head, compares its merge base, and passes an exact changed-file filter to the shared linter. Only Markdown, PowerShell, YAML, and workflow correctness/security checks are enabled. There is no path trigger filter, write token, publication, or label mutation. +Per-language status writes and Super-Linter's pull request summary comments are +explicitly disabled; the job itself supplies the named check. Rename detection +is disabled in the input scan so removing a fallback cannot hide its old path. The complete fixture and its LF-normalized SHA-256 in `validation.psd1` correspond to the [immutable complete scaffold][scaffold], including all five classifier diff --git a/.github/workflows/Organization-Defaults-Validation.yml b/.github/workflows/Organization-Defaults-Validation.yml index f54b0bd..753598d 100644 --- a/.github/workflows/Organization-Defaults-Validation.yml +++ b/.github/workflows/Organization-Defaults-Validation.yml @@ -50,6 +50,7 @@ jobs: env: DEFAULT_BRANCH: main MULTI_STATUS: false + ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false USE_FIND_ALGORITHM: true FILTER_REGEX_INCLUDE: ${{ steps.inputs.outputs.files_regex }} VALIDATE_MARKDOWN: true From 9a6470cc0c091ebaf7b28f816b6ea4879f28f329 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 14:43:06 +0100 Subject: [PATCH 05/16] Use only supported file-discovery linter settings --- .github/workflows/Organization-Defaults-Validation.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/Organization-Defaults-Validation.yml b/.github/workflows/Organization-Defaults-Validation.yml index 753598d..559e0dc 100644 --- a/.github/workflows/Organization-Defaults-Validation.yml +++ b/.github/workflows/Organization-Defaults-Validation.yml @@ -48,7 +48,6 @@ jobs: # yamllint disable-line rule:line-length uses: super-linter/super-linter@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0 env: - DEFAULT_BRANCH: main MULTI_STATUS: false ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false USE_FIND_ALGORITHM: true From 0e3961ed908fa8e80b98642c8f05db67a2b85435 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:08:29 +0100 Subject: [PATCH 06/16] Make changed-file filtering exact and cross-platform --- .github/VALIDATION.md | 6 ++- .github/scripts/PullRequestValidation.psm1 | 8 +++- .github/scripts/Test-OrganizationDefaults.ps1 | 3 +- .github/tests/PullRequestValidation.Tests.ps1 | 44 +++++++++++++++++++ .../Organization-Defaults-Validation.yml | 1 + 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index 4c58075..256896e 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -48,7 +48,8 @@ Run the same fixture and changed-input checks used by the read-only $npx = 'npx.cmd' } $files = @($inputs.MarkdownFiles | ForEach-Object { ':' + $_ }) - & $npx --yes -- markdownlint-cli2@0.24.0 --config '.github\linters\.markdown-lint.yml' @files + $markdownConfig = (Resolve-Path -LiteralPath '.github\linters\.markdown-lint.yml').ProviderPath + & $npx --yes -- markdownlint-cli2@0.24.0 --config $markdownConfig @files if ($LASTEXITCODE -ne 0) { throw 'Changed Markdown lint failed.' } @@ -69,6 +70,9 @@ is no path trigger filter, write token, publication, or label mutation. Per-language status writes and Super-Linter's pull request summary comments are explicitly disabled; the job itself supplies the named check. Rename detection is disabled in the input scan so removing a fallback cannot hide its old path. +The linter strips its workspace prefix and uses an anchored repository-relative +filter, so a changed `README.md` cannot select an unchanged `docs/README.md`. +Filenames containing control characters are rejected before filter generation. The complete fixture and its LF-normalized SHA-256 in `validation.psd1` correspond to the [immutable complete scaffold][scaffold], including all five classifier diff --git a/.github/scripts/PullRequestValidation.psm1 b/.github/scripts/PullRequestValidation.psm1 index ff8b786..6901a61 100644 --- a/.github/scripts/PullRequestValidation.psm1 +++ b/.github/scripts/PullRequestValidation.psm1 @@ -166,8 +166,8 @@ function Get-ValidationInput { throw 'Cannot enumerate the actual changed inputs.' } $paths = @(($changed -join "`n") -split "`0" | Where-Object { $_ }) - if (@($paths | Where-Object { $_ -match '[\r\n]' }).Count -gt 0) { - throw 'Changed filenames containing line breaks are unsupported; rename those files before validation.' + if (@($paths | Where-Object { $_ -match '[\x00-\x1f\x7f]' }).Count -gt 0) { + throw 'Changed filenames containing control characters are unsupported; rename those files before validation.' } $relevant = @($paths | Where-Object { $_ -match '\.(md|ps1|psm1|psd1|yml|yaml)$' }) $templates = @($relevant | Where-Object { @@ -181,6 +181,9 @@ function Get-ValidationInput { 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 @@ -188,6 +191,7 @@ function Get-ValidationInput { ExistingFiles = $existing MarkdownFiles = @($existing | Where-Object { $_ -match '\.md$' }) TemplateFiles = $templates + LintFilter = $filter } } diff --git a/.github/scripts/Test-OrganizationDefaults.ps1 b/.github/scripts/Test-OrganizationDefaults.ps1 index 96bb219..1376ee6 100644 --- a/.github/scripts/Test-OrganizationDefaults.ps1 +++ b/.github/scripts/Test-OrganizationDefaults.ps1 @@ -76,8 +76,7 @@ if ($inputs.MarkdownFiles.Count -eq 0) { Write-Information -MessageData 'No changed Markdown: only the applicable changed code/configuration is sent to its linters.' -InformationAction Continue } -$escapedPaths = @($inputs.ExistingFiles | ForEach-Object { [regex]::Escape($_) }) -$filter = '(^|/)(' + ($escapedPaths -join '|') + ')$' +$filter = $inputs.LintFilter if ($env:GITHUB_OUTPUT) { [IO.File]::AppendAllText($env:GITHUB_OUTPUT, "files_regex=$filter`n") } diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index 824bafe..4eb4496 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -37,6 +37,11 @@ BeforeDiscovery { @{ Name = 'unresolved source drift'; Content = $fixture.Replace('documented shared default', 'patch-only AutoPatching'); ExpectedError = '*pinned source*' } @{ Name = 'non-comment prefix'; Content = "# Unrelated heading`n$fixture"; ExpectedError = '*prefix*' } ) + $unsupportedFilenames = @( + @{ CaseName = 'a tab'; FileName = "changed`tfile.md" } + @{ CaseName = 'a form feed'; FileName = "changed$([char] 12)file.md" } + @{ CaseName = 'a delete control character'; FileName = "changed$([char] 127)file.md" } + ) } BeforeAll { @@ -94,6 +99,15 @@ Describe 'Actual committed validation inputs' { } } + Describe 'Native local Markdown configuration' { + It 'opens the provider-resolved configuration path with the native Node filesystem' { + $configPath = Join-Path -Path $PSScriptRoot -ChildPath '..\linters\.markdown-lint.yml' + $markdownConfig = (Resolve-Path -LiteralPath $configPath).ProviderPath + node -e 'require("node:fs").readFileSync(process.argv[1], "utf8")' $markdownConfig + $LASTEXITCODE | Should -Be 0 + } + } + It 'rejects an empty diff instead of counting self-tests as changed inputs' { { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | Should -Throw -ExpectedMessage '*No applicable changed input*' @@ -117,6 +131,36 @@ Describe 'Actual committed validation inputs' { $result.TemplateFiles | Should -HaveCount 0 } + It 'rejects a filename containing before selecting otherwise valid inputs' -ForEach $unsupportedFilenames { + $blob = git -C $repositoryPath rev-parse HEAD:base.md + # Index-only paths exercise Unix names without creating invalid Windows files. + git -C $repositoryPath -c core.protectNTFS=false update-index --add --cacheinfo "100644,$blob,$FileName" + $changedMarkdownPath = Join-Path -Path $repositoryPath -ChildPath 'changed.md' + [IO.File]::WriteAllText($changedMarkdownPath, "# Changed`n") + git -C $repositoryPath add -- $changedMarkdownPath + git -C $repositoryPath commit --quiet -m 'Record a control-character filename and valid Markdown' + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | + Should -Throw -ExpectedMessage '*control characters*' + } + + It 'produces an exact relative filter without selecting an unchanged suffix path' { + $directory = Join-Path -Path $repositoryPath -ChildPath 'docs' + $null = New-Item -ItemType Directory -Path $directory + $rootReadme = Join-Path -Path $repositoryPath -ChildPath 'README.md' + [IO.File]::WriteAllText($rootReadme, "# Root`n") + [IO.File]::WriteAllText((Join-Path -Path $directory -ChildPath 'README.md'), "# Nested`n") + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Record root and nested Markdown' + $baseRevision = git -C $repositoryPath rev-parse HEAD + [IO.File]::WriteAllText($rootReadme, "# Changed root`n") + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Change only the root Markdown' + $result = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision + $result.LintFilter | Should -BeExactly '^(README\.md)$' + 'README.md' | Should -Match $result.LintFilter + 'docs/README.md' | Should -Not -Match $result.LintFilter + } + It 'retains a deleted fallback as an applicable structural error' { $directory = Join-Path -Path $repositoryPath -ChildPath '.github' $null = New-Item -ItemType Directory -Path $directory diff --git a/.github/workflows/Organization-Defaults-Validation.yml b/.github/workflows/Organization-Defaults-Validation.yml index 559e0dc..a220119 100644 --- a/.github/workflows/Organization-Defaults-Validation.yml +++ b/.github/workflows/Organization-Defaults-Validation.yml @@ -51,6 +51,7 @@ jobs: 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 }} VALIDATE_MARKDOWN: true VALIDATE_POWERSHELL: true From 422ca608b064ca55275c9f502b45d6661b1cfc8b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:23:13 +0100 Subject: [PATCH 07/16] Validate every template location against a clean committed checkout --- .github/VALIDATION.md | 6 +- .github/scripts/PullRequestValidation.psm1 | 19 +++++- .github/tests/PullRequestValidation.Tests.ps1 | 62 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index 256896e..1b01acd 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -14,7 +14,8 @@ Run the same fixture and changed-input checks used by the read-only - For local code/configuration lint, also provide PSScriptAnalyzer, yamllint, actionlint, and zizmor. - Fetch the intended base and commit the changes being checked. The diff command - examines commits, not staged or untracked files. + examines commits, not staged or untracked files; validation rejects a dirty + working tree rather than certifying uncommitted corrections as HEAD. - Allow dependency restoration when needed: the source-native runner pins Pester 6.1.0 and verifies its module GUID. Markdown examples pin markdownlint-cli2 0.24.0. @@ -73,6 +74,9 @@ is disabled in the input scan so removing a fallback cannot hide its old path. The linter strips its workspace prefix and uses an anchored repository-relative filter, so a changed `README.md` cannot select an unchanged `docs/README.md`. Filenames containing control characters are rejected before filter generation. +Committed symbolic-link inputs are rejected, including links materialized as +regular files by a checkout. Single and named templates in the repository root, +`docs`, and `.github` all receive structural checks, including removed paths. The complete fixture and its LF-normalized SHA-256 in `validation.psd1` correspond to the [immutable complete scaffold][scaffold], including all five classifier diff --git a/.github/scripts/PullRequestValidation.psm1 b/.github/scripts/PullRequestValidation.psm1 index 6901a61..7015507 100644 --- a/.github/scripts/PullRequestValidation.psm1 +++ b/.github/scripts/PullRequestValidation.psm1 @@ -171,9 +171,26 @@ function Get-ValidationInput { } $relevant = @($paths | Where-Object { $_ -match '\.(md|ps1|psm1|psd1|yml|yaml)$' }) $templates = @($relevant | Where-Object { - $_ -match '^\.github/(pull_request_template\.md|PULL_REQUEST_TEMPLATE/[^/]+\.md)$' -or + $_ -match '^(?:(?:\.github|docs)/)?(?:pull_request_template\.md|PULL_REQUEST_TEMPLATE/[^/]+\.md)$' -or $_ -eq '.github/tests/fixtures/pull_request_template.md' }) + 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.' + } $existing = @($relevant | Where-Object { Test-Path -LiteralPath (Join-Path -Path $repository -ChildPath $_) -PathType Leaf }) diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index 4eb4496..99d599a 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -42,6 +42,18 @@ BeforeDiscovery { @{ CaseName = 'a form feed'; FileName = "changed$([char] 12)file.md" } @{ CaseName = 'a delete control character'; FileName = "changed$([char] 127)file.md" } ) + $lookupLocations = @( + @{ Location = 'pull_request_template.md' } + @{ Location = 'docs/pull_request_template.md' } + @{ Location = 'PULL_REQUEST_TEMPLATE/default.md' } + @{ Location = 'docs/PULL_REQUEST_TEMPLATE/default.md' } + ) + $dirtyInputs = @( + @{ Location = 'changed.md' } + @{ Location = '.github/pull_request_template.md' } + @{ Location = '.github/tests/fixtures/pull_request_template.md' } + @{ Location = '.github/validation.psd1' } + ) } BeforeAll { @@ -161,6 +173,56 @@ Describe 'Actual committed validation inputs' { 'docs/README.md' | Should -Not -Match $result.LintFilter } + It 'retains additions and deletions at lookup location ' -ForEach $lookupLocations { + $templatePath = Join-Path -Path $repositoryPath -ChildPath $Location + $null = New-Item -ItemType Directory -Path (Split-Path -Path $templatePath -Parent) -Force + [IO.File]::WriteAllText($templatePath, $valid) + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Add a supported lookup template' + $added = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision + $added.TemplateFiles | Should -Contain $Location + $baseRevision = git -C $repositoryPath rev-parse HEAD + Remove-Item -LiteralPath $templatePath + [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'changed.md'), "# Changed`n") + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Remove a supported lookup template' + $removed = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision + $removed.TemplateFiles | Should -Contain $Location + } + + It 'rejects a committed symbolic-link input even when Git materializes a regular file' { + $path = Join-Path -Path $repositoryPath -ChildPath 'linked.md' + [IO.File]::WriteAllText($path, $valid) + $blob = git -C $repositoryPath hash-object -w $path + git -C $repositoryPath config core.symlinks false + git -C $repositoryPath update-index --add --cacheinfo "120000,$blob,linked.md" + git -C $repositoryPath commit --quiet -m 'Record a symbolic-link input' + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | + Should -Throw -ExpectedMessage '*symbolic link*' + } + + It 'rejects uncommitted content at instead of certifying HEAD' -ForEach $dirtyInputs { + $path = Join-Path -Path $repositoryPath -ChildPath $Location + $null = New-Item -ItemType Directory -Path (Split-Path -Path $path -Parent) -Force + [IO.File]::WriteAllText($path, $valid) + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Record committed validation input' + [IO.File]::WriteAllText($path, "# Uncommitted correction`n") + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | + Should -Throw -ExpectedMessage '*working tree*' + } + + It 'rejects staged content that differs from the reported HEAD' { + $path = Join-Path -Path $repositoryPath -ChildPath 'changed.md' + [IO.File]::WriteAllText($path, "# Committed`n") + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Record committed Markdown' + [IO.File]::WriteAllText($path, "# Staged correction`n") + git -C $repositoryPath add --all + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | + Should -Throw -ExpectedMessage '*working tree*' + } + It 'retains a deleted fallback as an applicable structural error' { $directory = Join-Path -Path $repositoryPath -ChildPath '.github' $null = New-Item -ItemType Directory -Path $directory From 0cce9dc82862c8ea803a1e8b3fabf0d9112bf0e4 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 15:38:56 +0100 Subject: [PATCH 08/16] Reject inputs omitted by shared linter discovery --- .github/VALIDATION.md | 2 ++ .github/scripts/PullRequestValidation.psm1 | 8 +++++ .github/tests/PullRequestValidation.Tests.ps1 | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index 1b01acd..34a4637 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -74,6 +74,8 @@ is disabled in the input scan so removing a fallback cannot hide its old path. The linter strips its workspace prefix and uses an anchored repository-relative filter, so a changed `README.md` cannot select an unchanged `docs/README.md`. Filenames containing control characters are rejected before filter generation. +Literal backslashes and relevant files under the pinned finder's pruned +directories are rejected explicitly rather than counted as linted inputs. Committed symbolic-link inputs are rejected, including links materialized as regular files by a checkout. Single and named templates in the repository root, `docs`, and `.github` all receive structural checks, including removed paths. diff --git a/.github/scripts/PullRequestValidation.psm1 b/.github/scripts/PullRequestValidation.psm1 index 7015507..f9957e6 100644 --- a/.github/scripts/PullRequestValidation.psm1 +++ b/.github/scripts/PullRequestValidation.psm1 @@ -169,7 +169,15 @@ function Get-ValidationInput { 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.' + } $templates = @($relevant | Where-Object { $_ -match '^(?:(?:\.github|docs)/)?(?:pull_request_template\.md|PULL_REQUEST_TEMPLATE/[^/]+\.md)$' -or $_ -eq '.github/tests/fixtures/pull_request_template.md' diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index 99d599a..6a7b490 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -54,6 +54,14 @@ BeforeDiscovery { @{ Location = '.github/tests/fixtures/pull_request_template.md' } @{ Location = '.github/validation.psd1' } ) + $prunedDirectories = @( + @{ Directory = '.pytest_cache' } + @{ Directory = '.rbenv' } + @{ Directory = '.terragrunt-cache' } + @{ Directory = '.venv' } + @{ Directory = '__pycache__' } + @{ Directory = 'node_modules' } + ) } BeforeAll { @@ -173,6 +181,28 @@ Describe 'Actual committed validation inputs' { 'docs/README.md' | Should -Not -Match $result.LintFilter } + It 'rejects a literal backslash in a committed filename before path normalization' { + $blob = git -C $repositoryPath rev-parse HEAD:base.md + git -C $repositoryPath -c core.protectNTFS=false update-index --add --cacheinfo "100644,$blob,changed\file.md" + $path = Join-Path -Path $repositoryPath -ChildPath 'changed.md' + [IO.File]::WriteAllText($path, "# Changed`n") + git -C $repositoryPath add -- $path + git -C $repositoryPath commit --quiet -m 'Record a literal-backslash Git path' + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | + Should -Throw -ExpectedMessage '*backslash*' + } + + It 'rejects a selected file under linter-pruned directory ' -ForEach $prunedDirectories { + $directoryPath = Join-Path -Path $repositoryPath -ChildPath $Directory + $null = New-Item -ItemType Directory -Path $directoryPath + [IO.File]::WriteAllText((Join-Path -Path $directoryPath -ChildPath 'broken.md'), "# Broken!`n") + [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'changed.md'), "# Changed`n") + git -C $repositoryPath add --all + git -C $repositoryPath commit --quiet -m 'Record a linter-pruned input and normal Markdown' + { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | + Should -Throw -ExpectedMessage '*linter discovery*' + } + It 'retains additions and deletions at lookup location ' -ForEach $lookupLocations { $templatePath = Join-Path -Path $repositoryPath -ChildPath $Location $null = New-Item -ItemType Directory -Path (Split-Path -Path $templatePath -Parent) -Force From 66f252776c102a0b94ab6619f61b121b89f0b174 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 16:16:32 +0100 Subject: [PATCH 09/16] Clarify validation source and consumer inheritance scope --- .github/VALIDATION.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index 34a4637..efbf329 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -8,6 +8,19 @@ description: Check changed files and source-bound fallback scaffolds locally. Run the same fixture and changed-input checks used by the read-only `Organization defaults validation` job. +## Source and consumer scope + +This workflow and the commands below validate the special public +`MSXOrg/.github` source repository and its organization fallback structure. +The workflow, scripts, and fixtures are source-repository tooling, not +community files that GitHub inherits into ordinary repositories. + +Ordinary repositories inherit the organization's community defaults. +Keep local community files only when they contain repository-specific content; +do not copy shared policy or contribution files to satisfy this validator. +Consumers do not need to install this check. This work does not remove +existing local files, perform deduplication, or roll validation out to consumers. + ## Before starting - Use PowerShell 7.4 or later, Git, and Node.js/npm. From 86f294066bc2763a3abce07740848b63a5d7379b Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:25:30 +0100 Subject: [PATCH 10/16] Validate real Markdown without requiring PR templates --- .github/VALIDATION.md | 170 +++---- .github/scripts/PullRequestValidation.psm1 | 194 ++++---- .github/scripts/Test-OrganizationDefaults.ps1 | 62 ++- .github/tests/PullRequestValidation.Tests.ps1 | 433 ++++++++---------- .github/tests/WorkflowTriggers.Tests.ps1 | 52 +++ .../tests/fixtures/pull_request_template.md | 226 --------- .github/validation.psd1 | 12 +- .../Organization-Defaults-Validation.yml | 7 +- 8 files changed, 473 insertions(+), 683 deletions(-) create mode 100644 .github/tests/WorkflowTriggers.Tests.ps1 delete mode 100644 .github/tests/fixtures/pull_request_template.md diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index efbf329..16d8536 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -1,137 +1,95 @@ --- -title: Validate organization-default pull requests -description: Check changed files and source-bound fallback scaffolds locally. +title: Validate community-default source changes +description: Check committed source files without requiring a PR template. --- -# Validate organization-default pull requests +# Validate community-default source changes -Run the same fixture and changed-input checks used by the read-only -`Organization defaults validation` job. - -## Source and consumer scope - -This workflow and the commands below validate the special public -`MSXOrg/.github` source repository and its organization fallback structure. -The workflow, scripts, and fixtures are source-repository tooling, not -community files that GitHub inherits into ordinary repositories. - -Ordinary repositories inherit the organization's community defaults. -Keep local community files only when they contain repository-specific content; -do not copy shared policy or contribution files to satisfy this validator. -Consumers do not need to install this check. This work does not remove -existing local files, perform deduplication, or roll validation out to consumers. +`Organization defaults validation` checks pull requests to `main` in the +special public `MSXOrg/.github` source repository. ## Before starting -- Use PowerShell 7.4 or later, Git, and Node.js/npm. -- For local code/configuration lint, also provide PSScriptAnalyzer, yamllint, - actionlint, and zizmor. -- Fetch the intended base and commit the changes being checked. The diff command - examines commits, not staged or untracked files; validation rejects a dirty - working tree rather than certifying uncommitted corrections as HEAD. -- Allow dependency restoration when needed: the source-native runner pins - Pester 6.1.0 and verifies its module GUID. Markdown examples pin - markdownlint-cli2 0.24.0. +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. ## Steps -1. Fetch the base: `git fetch origin main`. -2. Run the valid, malformed, and changed-input fixture suite: +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 -Path .github\tests -RequiredVersion 6.1.0 ``` -3. Validate the committed inputs and any changed complete fallback: +3. Validate the actual committed inputs, shared sources, and changed Markdown: ```powershell pwsh -NoProfile -File .github\scripts\Test-OrganizationDefaults.ps1 -BaseRevision origin/main ``` -4. Lint the actual changed Markdown with the shared configuration: - - ```powershell - $ErrorActionPreference = 'Stop' - Import-Module -Name (Resolve-Path -LiteralPath '.github\scripts\PullRequestValidation.psm1').ProviderPath -Force - $inputs = Get-ValidationInput -RepositoryPath $PWD.ProviderPath -BaseRevision 'origin/main' - if ($inputs.MarkdownFiles.Count -eq 0) { - throw 'No changed Markdown: this command cannot report a Markdown pass.' - } - $npx = 'npx' - if ($IsWindows) { - $npx = 'npx.cmd' - } - $files = @($inputs.MarkdownFiles | ForEach-Object { ':' + $_ }) - $markdownConfig = (Resolve-Path -LiteralPath '.github\linters\.markdown-lint.yml').ProviderPath - & $npx --yes -- markdownlint-cli2@0.24.0 --config $markdownConfig @files - if ($LASTEXITCODE -ne 0) { - throw 'Changed Markdown lint failed.' - } - ``` - ## Verify -The fixture suite must discover tests, reject malformed scaffolds, and pass its -valid cases. The changed-input report identifies the actual head and counts -changed Markdown and production templates separately from its isolated fixture. -Empty/unsupported-only diffs fail explicitly. Unchanged fallback content is not -certified as the candidate. A changed missing or source-divergent fallback fails. - -The hosted job checks out the pull request head, compares its merge base, and -passes an exact changed-file filter to the shared linter. Only Markdown, -PowerShell, YAML, and workflow correctness/security checks are enabled. There -is no path trigger filter, write token, publication, or label mutation. -Per-language status writes and Super-Linter's pull request summary comments are -explicitly disabled; the job itself supplies the named check. Rename detection -is disabled in the input scan so removing a fallback cannot hide its old path. -The linter strips its workspace prefix and uses an anchored repository-relative -filter, so a changed `README.md` cannot select an unchanged `docs/README.md`. -Filenames containing control characters are rejected before filter generation. -Literal backslashes and relevant files under the pinned finder's pruned -directories are rejected explicitly rather than counted as linted inputs. -Committed symbolic-link inputs are rejected, including links materialized as -regular files by a checkout. Single and named templates in the repository root, -`docs`, and `.github` all receive structural checks, including removed paths. - -The complete fixture and its LF-normalized SHA-256 in `validation.psd1` correspond -to the [immutable complete scaffold][scaffold], including all five classifier -groups, scoped supporting pairs, adoption/plan/convergence prompts, and conditional -final decision fields. This source snapshot is not a shipped producer runtime or -an installation dependency. The check validates template code, not filled-in PR -descriptions or a producer's effective release decision. - -The [shared Markdown configuration][markdown], [PowerShell configuration][powershell], -and [Pester runner][pester] are vendored from an immutable default-branch revision, -as is the [shared zizmor configuration][zizmor]. -The YAML configuration matches the [pinned shared linter baseline][yaml]. -Refresh a snapshot only against a selected immutable source, compare the complete -source content independently, and rerun the valid/malformed suite. - -For local code/configuration checks, use `Invoke-ScriptAnalyzer` with -`.github\linters\.powershell-psscriptanalyzer.psd1`, -`yamllint -c .github\linters\.yaml-lint.yml`, `actionlint`, and -`zizmor --offline -c .github\linters\zizmor.yml`. -Hosted checks use the pinned Super-Linter integration; -local fixture results do not replace a completed result at the actual PR head. - -GitHub-native Dependabot uses its [default three-day cooldown][cooldown]. -The shared zizmor configuration disables only the outdated missing-cooldown -audit; all other workflow audits remain enabled. No cooldown override or -label configuration is introduced. +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. + +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. + +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. + +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 count fixture-only evidence as a changed-input pass. | -| Scaffold/source mismatch | Changed marker, groups, prompts, or decision structure | Restore the complete selected source scaffold and retain only comment-based authoring guidance before it. | -| Head mismatch or missing merge base | Wrong checkout or incomplete history | Fetch the intended base/head and rerun at the actual PR head. | -| Passing check does not hold merge | No required-check rule | Have the governance owner separately approve requiring `Organization defaults validation` from GitHub Actions for `main`. This code does not modify rulesets or access. | +| 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. | -[scaffold]: https://github.com/MSXOrg/docs/blob/1ececb2bd9f1680c87cec15a45ac927a554fce9c/src/docs/Ways-of-Working/PR-Format.md#copy-the-complete-template +[pr-format]: https://msx.no/docs/Ways-of-Working/PR-Format/ [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 -[pester]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/scripts/Invoke-PesterSuite.ps1 -[yaml]: https://github.com/super-linter/super-linter/blob/4ce20838b8ab83717e78138c5b3a1407148e0918/TEMPLATES/.yaml-lint.yml [zizmor]: https://github.com/MSXOrg/docs/blob/36da9154aa55d2052345fb2f28b812b29daf8bc9/.github/linters/zizmor.yaml -[cooldown]: https://github.blog/changelog/2026-07-14-dependabot-version-updates-introduce-default-package-cooldown/ +[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/scripts/PullRequestValidation.psm1 b/.github/scripts/PullRequestValidation.psm1 index f9957e6..d27b615 100644 --- a/.github/scripts/PullRequestValidation.psm1 +++ b/.github/scripts/PullRequestValidation.psm1 @@ -3,108 +3,149 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -function Test-PullRequestTemplate { +function Test-ValidationSource { <# .SYNOPSIS - Validate a complete organization fallback against its immutable source. + Verify the shared validation configuration snapshots. .DESCRIPTION - Check the announcement boundary, classifier order, scoped supporting pairs, - final conditional decision fields, and complete source correspondence. - Only HTML authoring comments may precede the recorded scaffold. + Compare Git-normalized configuration blobs with their recorded immutable + sources. This verifies tooling configuration, not a copied PR template. .EXAMPLE - Test-PullRequestTemplate -Content $template - Return structural counts and the verified source revision. + Test-ValidationSource -RepositoryPath $repository + Return the number of verified configurations and the source revision. .INPUTS None - You can't pipe objects to Test-PullRequestTemplate. + You can't pipe objects to Test-ValidationSource. .OUTPUTS System.Management.Automation.PSCustomObject - The verified source revision, digest, and structural counts. + The verified configuration count and immutable source revision. #> [OutputType([pscustomobject])] [CmdletBinding()] param( - # Complete template text, not a filled-in pull request description. + # Root of the clean source checkout being validated. [Parameter(Mandatory)] - [AllowEmptyString()] - [string] $Content + [ValidateNotNullOrEmpty()] + [string] $RepositoryPath ) - $content = ($Content -replace '\r\n?', "`n").TrimStart([char] 0xfeff) - if ([string]::IsNullOrWhiteSpace($content)) { - throw 'The fallback template is empty.' + $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.' } - - $marker = '' - if ([regex]::Matches($content, [regex]::Escape($marker)).Count -ne 1 -or - [regex]::Matches($content, '(?m)^$').Count -ne 1) { - throw 'The fallback must contain exactly one standalone announcement marker.' + 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." + } } - $starts = [regex]::Matches($content, '(?m)^\{\{ One or two short summary paragraphs') - if ($starts.Count -ne 1) { - throw 'The complete scaffold is missing or ambiguous; restore the pinned source.' - } - $prefix = $content.Substring(0, $starts[0].Index) -replace '(?s)', '' - if (-not [string]::IsNullOrWhiteSpace($prefix)) { - throw 'The scaffold prefix may contain only complete HTML authoring comments.' + [pscustomobject]@{ + CheckedFiles = $source.ConfigFiles.Count + SourceCommit = $source.SourceCommit } - $body = $content.Substring($starts[0].Index).TrimEnd() + "`n" +} - $classifiers = [regex]::Matches($body, '(?m)^## (?Breaking|Removed|New|Changed|Fixed):') - $names = @($classifiers | ForEach-Object { $_.Groups['name'].Value }) - if (($names -join ',') -cne 'Breaking,Removed,New,Changed,Fixed') { - throw 'The complete scaffold requires five ordered classifier groups: Breaking, Removed, New, Changed, Fixed.' - } +function Test-MarkdownFile { + <# + .SYNOPSIS + Lint every supplied Markdown file with the shared rules. - $release = [regex]::Matches($body, '(?s)
\nRelease decisions.*?
') - if ($release.Count -ne 1 -or $release[0].Index -lt $classifiers[4].Index -or - $body.Substring($release[0].Index).TrimEnd() -cne $release[0].Value) { - throw 'The complete scaffold requires one final release-decision block after all result groups.' - } + .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 - $sections = [regex]::Matches( - $body, - '(?ms)^## (?:Breaking|Removed|New|Changed|Fixed):.*?(?=^## |
\nRelease decisions|\z)' + 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 ) - foreach ($section in $sections) { - $technical = [regex]::Matches($section.Value, '(?s)
\nTechnical details.*?
') - $references = [regex]::Matches($section.Value, '(?s)
\nRelated references.*?
') - if ($technical.Count -ne 1 -or $references.Count -ne 1 -or - $technical[0].Index -gt $references[0].Index -or - [regex]::Matches($section.Value, '
').Count -ne 2) { - throw 'Every classifier requires one scoped Technical details block followed by one scoped Related references block.' - } - } - $fields = [regex]::Matches($release[0].Value, '(?m)^- \*\*(?Release|Version bump|Mode|Labels):\*\*') - $fieldNames = @($fields | ForEach-Object { $_.Groups['name'].Value }) - if (($fieldNames -join ',') -cne 'Release,Version bump,Mode,Labels') { - throw 'The final release-decision fields must be Release, Version bump, Mode, and Labels, in order.' + if ($Path.Count -eq 0) { + throw 'No Markdown input was supplied; a zero-file lint is not a pass.' } - - $sourcePath = Join-Path -Path $PSScriptRoot -ChildPath '..\validation.psd1' - $source = Import-PowerShellDataFile -LiteralPath $sourcePath - $digest = [Convert]::ToHexString( - [Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($body)) - ).ToLowerInvariant() - if ($digest -cne $source.TemplateSha256) { - throw "The complete scaffold differs from the pinned source $($source.SourceRepository)@$($source.SourceCommit)." + $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]@{ - SourceCommit = $source.SourceCommit - TemplateSha256 = $digest - AnnouncementMarkers = 1 - Classifiers = $classifiers.Count - ScopedDetails = $sections.Count - ReleaseDecisionBlocks = $release.Count + CheckedFiles = $files.Count + ToolVersion = $source.MarkdownlintVersion } } @@ -114,7 +155,7 @@ function Get-ValidationInput { Identify the actual committed inputs changed by a pull request. .DESCRIPTION - Compare the merge base with HEAD, retain deleted templates for explicit failure, + 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. @@ -130,7 +171,7 @@ function Get-ValidationInput { .OUTPUTS System.Management.Automation.PSCustomObject - Commit identities and changed input, Markdown, and template paths. + Commit identities and existing, deleted, and Markdown input paths. #> [OutputType([pscustomobject])] [CmdletBinding()] @@ -160,7 +201,7 @@ function Get-ValidationInput { if ($LASTEXITCODE -ne 0) { throw 'Cannot find the merge base. Fetch the complete base and head history.' } - # Retain the old path when a rename removes a GitHub template lookup file. + # 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.' @@ -178,10 +219,6 @@ function Get-ValidationInput { 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.' } - $templates = @($relevant | Where-Object { - $_ -match '^(?:(?:\.github|docs)/)?(?:pull_request_template\.md|PULL_REQUEST_TEMPLATE/[^/]+\.md)$' -or - $_ -eq '.github/tests/fixtures/pull_request_template.md' - }) if ($relevant.Count -gt 0) { $literalPaths = @($relevant | ForEach-Object { ':(literal)' + $_ }) $modes = git -C $repository ls-tree -r -z --format='%(objectmode)' $headCommit -- @literalPaths @@ -202,7 +239,8 @@ function Get-ValidationInput { $existing = @($relevant | Where-Object { Test-Path -LiteralPath (Join-Path -Path $repository -ChildPath $_) -PathType Leaf }) - if ($existing.Count -eq 0 -and $templates.Count -eq 0) { + $deleted = @($relevant | Where-Object { $_ -notin $existing }) + if ($relevant.Count -eq 0) { throw 'No applicable changed input was found. Fixture self-tests are not a complete pull request validation pass.' } @@ -215,9 +253,9 @@ function Get-ValidationInput { ChangedFiles = $relevant ExistingFiles = $existing MarkdownFiles = @($existing | Where-Object { $_ -match '\.md$' }) - TemplateFiles = $templates + DeletedFiles = $deleted LintFilter = $filter } } -Export-ModuleMember -Function Test-PullRequestTemplate, Get-ValidationInput +Export-ModuleMember -Function Test-ValidationSource, Test-MarkdownFile, Get-ValidationInput diff --git a/.github/scripts/Test-OrganizationDefaults.ps1 b/.github/scripts/Test-OrganizationDefaults.ps1 index 1376ee6..6828050 100644 --- a/.github/scripts/Test-OrganizationDefaults.ps1 +++ b/.github/scripts/Test-OrganizationDefaults.ps1 @@ -2,11 +2,11 @@ <# .SYNOPSIS - Validate actual changed inputs and the complete organization fallback. + Validate the source repository's actual changed inputs. .DESCRIPTION - Check the actual head and changed templates, verify the isolated source fixture, - and report changed Markdown separately from unchanged fallback content. + 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 @@ -21,7 +21,7 @@ .OUTPUTS System.Management.Automation.PSCustomObject - The actual head and counts of changed inputs and checked templates. + The actual head and counts of checked content, deletions, and configuration sources. #> [OutputType([pscustomobject])] [CmdletBinding()] @@ -51,42 +51,32 @@ Write-Information -MessageData "Actual head: $($inputs.HeadCommit); merge base: foreach ($path in $inputs.ChangedFiles) { Write-Information -MessageData "Applicable changed input: $path" -InformationAction Continue } -foreach ($path in $inputs.MarkdownFiles) { - $content = [IO.File]::ReadAllText((Join-Path -Path $repository -ChildPath $path)) - if ([string]::IsNullOrWhiteSpace($content)) { - throw "Changed Markdown '$path' is empty; no Markdown content was validated." - } -} - -$fixturePath = Join-Path -Path $PSScriptRoot -ChildPath '..\tests\fixtures\pull_request_template.md' -$fixture = Test-PullRequestTemplate -Content ([IO.File]::ReadAllText($fixturePath)) -$productionTemplates = @($inputs.TemplateFiles | Where-Object { $_ -ne '.github/tests/fixtures/pull_request_template.md' }) -foreach ($path in $inputs.TemplateFiles) { - $templatePath = Join-Path -Path $repository -ChildPath $path - if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { - throw "Changed fallback '$path' is missing. Restore its complete source-aligned scaffold." - } - $null = Test-PullRequestTemplate -Content ([IO.File]::ReadAllText($templatePath)) - Write-Information -MessageData "Source-aligned changed template: $path" -InformationAction Continue -} -if ($productionTemplates.Count -eq 0) { - Write-Information -MessageData 'No production fallback changed: unchanged legacy content is not certified as the candidate.' -InformationAction Continue -} +$source = Test-ValidationSource -RepositoryPath $repository +$checkedMarkdown = 0 if ($inputs.MarkdownFiles.Count -eq 0) { - Write-Information -MessageData 'No changed Markdown: only the applicable changed code/configuration is sent to its linters.' -InformationAction Continue + 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`n") + [IO.File]::AppendAllText($env:GITHUB_OUTPUT, "files_regex=$filter`nexisting_files=$($inputs.ExistingFiles.Count)`n") } $report = [pscustomobject]@{ HeadCommit = $inputs.HeadCommit ChangedInputs = $inputs.ExistingFiles.Count - ChangedMarkdown = $inputs.MarkdownFiles.Count - ChangedProductionTemplates = $productionTemplates.Count - SourceFixtureTemplates = 1 - SourceCommit = $fixture.SourceCommit + CheckedMarkdown = $checkedMarkdown + CheckedDeletions = $inputs.DeletedFiles.Count + ConfigurationSources = $source.CheckedFiles + SourceCommit = $source.SourceCommit } if ($env:GITHUB_STEP_SUMMARY) { $summary = @( @@ -95,12 +85,12 @@ if ($env:GITHUB_STEP_SUMMARY) { "| Actual head | ``$($report.HeadCommit)`` |" '| --- | --- |' "| Applicable changed files sent to linters | $($report.ChangedInputs) |" - "| Changed Markdown | $($report.ChangedMarkdown) |" - "| Changed production templates structurally checked | $($report.ChangedProductionTemplates) |" - '| Isolated source fixtures checked (not unchanged fallback evidence) | 1 |' - "| Immutable fixture source | ``$($report.SourceCommit)`` |" + "| 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)`` |" '' - 'Fixture checks do not certify unchanged legacy templates. Lint and Pester results follow separately.' + '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") } diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index 6a7b490..ce42354 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -3,7 +3,7 @@ [Diagnostics.CodeAnalysis.SuppressMessageAttribute( 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Pester discovers and executes setup and test blocks in separate scopes.' + Justification = 'Pester setup and test blocks execute in separate scopes.' )] [CmdletBinding()] param() @@ -11,283 +11,252 @@ param() $ErrorActionPreference = 'Stop' BeforeDiscovery { - $fixturePath = Join-Path -Path $PSScriptRoot -ChildPath 'fixtures\pull_request_template.md' - $fixture = [IO.File]::ReadAllText($fixturePath) -replace '\r\n?', "`n" - $marker = '' - $technical = [regex]::Match($fixture, '(?s)
\nTechnical details.*?
').Value - $references = [regex]::Match($fixture, '(?s)
\nRelated references.*?
').Value - $release = [regex]::Match($fixture, '(?s)
\nRelease decisions.*?
').Value - $malformed = @( - @{ Name = 'missing announcement marker'; Content = $fixture.Replace($marker, ''); ExpectedError = '*announcement marker*' } - @{ Name = 'duplicate announcement marker'; Content = $fixture.Replace($marker, "$marker`n$marker"); ExpectedError = '*announcement marker*' } - @{ Name = 'fenced announcement marker'; Content = $fixture.Replace($marker, "``````markdown`n$marker`n``````"); ExpectedError = '*pinned source*' } - @{ Name = 'marker after a result'; Content = $fixture.Replace("$marker`n", '').Replace('## Removed:', "$marker`n## Removed:"); ExpectedError = '*pinned source*' } - @{ Name = 'missing classifier'; Content = $fixture.Replace('## Removed:', '## Other:'); ExpectedError = '*classifier*' } - @{ Name = 'unordered classifiers'; Content = $fixture.Replace('## Breaking:', '## Swap:').Replace('## New:', '## Breaking:').Replace('## Swap:', '## New:'); ExpectedError = '*classifier*' } - @{ Name = 'missing scoped evidence'; Content = $fixture.Replace($technical, ''); ExpectedError = '*scoped*' } - @{ Name = 'missing scoped references'; Content = $fixture.Replace($references, ''); ExpectedError = '*scoped*' } - @{ Name = 'references before evidence'; Content = $fixture.Replace("$technical`n`n$references", "$references`n`n$technical"); ExpectedError = '*scoped*' } - @{ Name = 'global evidence'; Content = $technical + "`n" + $fixture.Replace($technical, ''); ExpectedError = '*prefix*' } - @{ Name = 'missing final decision'; Content = $fixture.Replace($release, ''); ExpectedError = '*final release-decision*' } - @{ Name = 'decision before results'; Content = $fixture.Replace($release, '').Replace($marker, "$marker`n$release"); ExpectedError = '*final release-decision*' } - @{ Name = 'duplicate decision'; Content = $fixture + $release; ExpectedError = '*final release-decision*' } - @{ Name = 'missing resolved bump'; Content = $fixture.Replace('- **Version bump:**', '- **Other:**'); ExpectedError = '*release-decision fields*' } - @{ Name = 'missing conditional no-release guidance'; Content = $fixture.Replace("Delete this field when Release`n is No.", ''); ExpectedError = '*pinned source*' } - @{ Name = 'missing adoption instructions'; Content = $fixture.Replace('adoption actions or an explicit no-action result.', 'See the diff for instructions.'); ExpectedError = '*pinned source*' } - @{ Name = 'unresolved source drift'; Content = $fixture.Replace('documented shared default', 'patch-only AutoPatching'); ExpectedError = '*pinned source*' } - @{ Name = 'non-comment prefix'; Content = "# Unrelated heading`n$fixture"; ExpectedError = '*prefix*' } + $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*' } ) - $unsupportedFilenames = @( - @{ CaseName = 'a tab'; FileName = "changed`tfile.md" } - @{ CaseName = 'a form feed'; FileName = "changed$([char] 12)file.md" } - @{ CaseName = 'a delete control character'; FileName = "changed$([char] 127)file.md" } - ) - $lookupLocations = @( - @{ Location = 'pull_request_template.md' } - @{ Location = 'docs/pull_request_template.md' } - @{ Location = 'PULL_REQUEST_TEMPLATE/default.md' } - @{ Location = 'docs/PULL_REQUEST_TEMPLATE/default.md' } - ) - $dirtyInputs = @( - @{ Location = 'changed.md' } - @{ Location = '.github/pull_request_template.md' } - @{ Location = '.github/tests/fixtures/pull_request_template.md' } - @{ Location = '.github/validation.psd1' } - ) - $prunedDirectories = @( - @{ Directory = '.pytest_cache' } - @{ Directory = '.rbenv' } - @{ Directory = '.terragrunt-cache' } - @{ Directory = '.venv' } - @{ Directory = '__pycache__' } - @{ Directory = 'node_modules' } + $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 { - $modulePath = Join-Path -Path $PSScriptRoot -ChildPath '..\scripts\PullRequestValidation.psm1' - Import-Module -Name $modulePath -Force - $fixturePath = Join-Path -Path $PSScriptRoot -ChildPath 'fixtures\pull_request_template.md' - $valid = [IO.File]::ReadAllText($fixturePath) -replace '\r\n?', "`n" + $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 'Pinned complete fallback scaffold' { - It 'accepts the independently recorded source fixture' { - $result = Test-PullRequestTemplate -Content $valid - $result.TemplateSha256 | Should -BeExactly '94fd209828f902b5faf4af9248826078843ea1ce37f168049606b53cfb6802c4' - $result.AnnouncementMarkers | Should -Be 1 - $result.Classifiers | Should -Be 5 - $result.ScopedDetails | Should -Be 5 - $result.ReleaseDecisionBlocks | Should -Be 1 +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 'accepts a comment-only authoring wrapper and CRLF line endings' { - $content = "`n" + $valid - $result = Test-PullRequestTemplate -Content ($content -replace "`n", "`r`n") - $result.Classifiers | Should -Be 5 + 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 'rejects ' -ForEach $malformed { - { Test-PullRequestTemplate -Content $Content } | Should -Throw -ExpectedMessage $ExpectedError + 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 'rejects empty content rather than returning a pass' { - { Test-PullRequestTemplate -Content '' } | Should -Throw -ExpectedMessage '*empty*' + 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 identify the unchanged legacy fallback as the candidate' { - $legacy = "`n---`n
Technical details
" - { Test-PullRequestTemplate -Content $legacy } | Should -Throw -ExpectedMessage '*announcement marker*' + 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*' } -} -Describe 'Actual committed validation inputs' { - BeforeEach { - $PSNativeCommandUseErrorActionPreference = $true - $repositoryPath = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString()) - $null = New-Item -ItemType Directory -Path $repositoryPath - git -C $repositoryPath init --quiet --initial-branch=main - git -C $repositoryPath config user.name 'Validation fixture' - git -C $repositoryPath config user.email 'fixture@example.invalid' - git -C $repositoryPath config core.autocrlf false - [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'base.md'), "# Base`n") - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Record anonymous base fixture' - $baseRevision = git -C $repositoryPath rev-parse HEAD - if ($LASTEXITCODE -ne 0) { - throw 'Could not create the anonymous Git fixture.' - } + 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 } - Describe 'Native local Markdown configuration' { - It 'opens the provider-resolved configuration path with the native Node filesystem' { - $configPath = Join-Path -Path $PSScriptRoot -ChildPath '..\linters\.markdown-lint.yml' - $markdownConfig = (Resolve-Path -LiteralPath $configPath).ProviderPath - node -e 'require("node:fs").readFileSync(process.argv[1], "utf8")' $markdownConfig - $LASTEXITCODE | Should -Be 0 - } + 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 an empty diff instead of counting self-tests as changed inputs' { - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | - Should -Throw -ExpectedMessage '*No applicable changed input*' + 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 a media-only diff as no applicable input' { - [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'image.png'), 'anonymous fixture') - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Add anonymous non-Markdown input' - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | - Should -Throw -ExpectedMessage '*No applicable changed input*' + 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 'selects committed changed Markdown with spaces but not unchanged Markdown' { - [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'changed file.md'), "# Changed`n") - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Add changed Markdown input' - $result = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision - $result.MarkdownFiles | Should -HaveCount 1 - $result.MarkdownFiles[0] | Should -BeExactly 'changed file.md' - $result.TemplateFiles | Should -HaveCount 0 + 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 a filename containing before selecting otherwise valid inputs' -ForEach $unsupportedFilenames { - $blob = git -C $repositoryPath rev-parse HEAD:base.md - # Index-only paths exercise Unix names without creating invalid Windows files. - git -C $repositoryPath -c core.protectNTFS=false update-index --add --cacheinfo "100644,$blob,$FileName" - $changedMarkdownPath = Join-Path -Path $repositoryPath -ChildPath 'changed.md' - [IO.File]::WriteAllText($changedMarkdownPath, "# Changed`n") - git -C $repositoryPath add -- $changedMarkdownPath - git -C $repositoryPath commit --quiet -m 'Record a control-character filename and valid Markdown' - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | - Should -Throw -ExpectedMessage '*control characters*' + 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 'produces an exact relative filter without selecting an unchanged suffix path' { - $directory = Join-Path -Path $repositoryPath -ChildPath 'docs' - $null = New-Item -ItemType Directory -Path $directory - $rootReadme = Join-Path -Path $repositoryPath -ChildPath 'README.md' - [IO.File]::WriteAllText($rootReadme, "# Root`n") - [IO.File]::WriteAllText((Join-Path -Path $directory -ChildPath 'README.md'), "# Nested`n") - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Record root and nested Markdown' - $baseRevision = git -C $repositoryPath rev-parse HEAD - [IO.File]::WriteAllText($rootReadme, "# Changed root`n") - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Change only the root Markdown' - $result = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision - $result.LintFilter | Should -BeExactly '^(README\.md)$' - 'README.md' | Should -Match $result.LintFilter - 'docs/README.md' | Should -Not -Match $result.LintFilter + It 'fails explicitly when the intended base is unavailable' { + { Get-ValidationInput -RepositoryPath $repository -BaseRevision 'missing-reference' } | + Should -Throw -ExpectedMessage '*base revision*' } +} - It 'rejects a literal backslash in a committed filename before path normalization' { - $blob = git -C $repositoryPath rev-parse HEAD:base.md - git -C $repositoryPath -c core.protectNTFS=false update-index --add --cacheinfo "100644,$blob,changed\file.md" - $path = Join-Path -Path $repositoryPath -ChildPath 'changed.md' - [IO.File]::WriteAllText($path, "# Changed`n") - git -C $repositoryPath add -- $path - git -C $repositoryPath commit --quiet -m 'Record a literal-backslash Git path' - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | - Should -Throw -ExpectedMessage '*backslash*' +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 'rejects a selected file under linter-pruned directory ' -ForEach $prunedDirectories { - $directoryPath = Join-Path -Path $repositoryPath -ChildPath $Directory - $null = New-Item -ItemType Directory -Path $directoryPath - [IO.File]::WriteAllText((Join-Path -Path $directoryPath -ChildPath 'broken.md'), "# Broken!`n") - [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'changed.md'), "# Changed`n") - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Record a linter-pruned input and normal Markdown' - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | - Should -Throw -ExpectedMessage '*linter discovery*' + It 'does not count a zero-file Markdown scan as success' { + { Test-MarkdownFile -Path @() -ConfigurationPath $markdownConfig } | + Should -Throw -ExpectedMessage '*No Markdown input*' } - It 'retains additions and deletions at lookup location ' -ForEach $lookupLocations { - $templatePath = Join-Path -Path $repositoryPath -ChildPath $Location - $null = New-Item -ItemType Directory -Path (Split-Path -Path $templatePath -Parent) -Force - [IO.File]::WriteAllText($templatePath, $valid) - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Add a supported lookup template' - $added = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision - $added.TemplateFiles | Should -Contain $Location - $baseRevision = git -C $repositoryPath rev-parse HEAD - Remove-Item -LiteralPath $templatePath - [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'changed.md'), "# Changed`n") - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Remove a supported lookup template' - $removed = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision - $removed.TemplateFiles | Should -Contain $Location + 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*' } +} - It 'rejects a committed symbolic-link input even when Git materializes a regular file' { - $path = Join-Path -Path $repositoryPath -ChildPath 'linked.md' - [IO.File]::WriteAllText($path, $valid) - $blob = git -C $repositoryPath hash-object -w $path - git -C $repositoryPath config core.symlinks false - git -C $repositoryPath update-index --add --cacheinfo "120000,$blob,linked.md" - git -C $repositoryPath commit --quiet -m 'Record a symbolic-link input' - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | - Should -Throw -ExpectedMessage '*symbolic link*' +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 uncommitted content at instead of certifying HEAD' -ForEach $dirtyInputs { - $path = Join-Path -Path $repositoryPath -ChildPath $Location - $null = New-Item -ItemType Directory -Path (Split-Path -Path $path -Parent) -Force - [IO.File]::WriteAllText($path, $valid) - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Record committed validation input' - [IO.File]::WriteAllText($path, "# Uncommitted correction`n") - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | - Should -Throw -ExpectedMessage '*working tree*' + 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 staged content that differs from the reported HEAD' { - $path = Join-Path -Path $repositoryPath -ChildPath 'changed.md' - [IO.File]::WriteAllText($path, "# Committed`n") - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Record committed Markdown' - [IO.File]::WriteAllText($path, "# Staged correction`n") - git -C $repositoryPath add --all - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision } | - Should -Throw -ExpectedMessage '*working tree*' + 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*' } +} - It 'retains a deleted fallback as an applicable structural error' { - $directory = Join-Path -Path $repositoryPath -ChildPath '.github' - $null = New-Item -ItemType Directory -Path $directory - $templatePath = Join-Path -Path $directory -ChildPath 'pull_request_template.md' - [IO.File]::WriteAllText($templatePath, $valid) - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Record the source-aligned fallback' - $baseRevision = git -C $repositoryPath rev-parse HEAD - Remove-Item -LiteralPath $templatePath - [IO.File]::WriteAllText((Join-Path -Path $repositoryPath -ChildPath 'changed.md'), "# Changed`n") - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Remove the fallback while changing Markdown' - $result = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision - $result.TemplateFiles | Should -Contain '.github/pull_request_template.md' - $result.MarkdownFiles | Should -Not -Contain '.github/pull_request_template.md' +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 'retains the old path when a fallback is renamed outside template lookup' { - $directory = Join-Path -Path $repositoryPath -ChildPath '.github' - $null = New-Item -ItemType Directory -Path $directory - $templatePath = Join-Path -Path $directory -ChildPath 'pull_request_template.md' - [IO.File]::WriteAllText($templatePath, $valid) - git -C $repositoryPath add --all - git -C $repositoryPath commit --quiet -m 'Record the source-aligned fallback' - $baseRevision = git -C $repositoryPath rev-parse HEAD - $renamedPath = Join-Path -Path $directory -ChildPath 'renamed.md' - git -C $repositoryPath mv -- $templatePath $renamedPath - git -C $repositoryPath commit --quiet -m 'Move the fallback outside template lookup' - $result = Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision $baseRevision - $result.TemplateFiles | Should -Contain '.github/pull_request_template.md' - $result.MarkdownFiles | Should -Contain '.github/renamed.md' + 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 'fails explicitly for an invalid base revision' { - { Get-ValidationInput -RepositoryPath $repositoryPath -BaseRevision 'missing-reference' } | - Should -Throw -ExpectedMessage '*base revision*' + 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 } } diff --git a/.github/tests/WorkflowTriggers.Tests.ps1 b/.github/tests/WorkflowTriggers.Tests.ps1 new file mode 100644 index 0000000..c4e0ea5 --- /dev/null +++ b/.github/tests/WorkflowTriggers.Tests.ps1 @@ -0,0 +1,52 @@ +#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' + } +} diff --git a/.github/tests/fixtures/pull_request_template.md b/.github/tests/fixtures/pull_request_template.md deleted file mode 100644 index 25a3802..0000000 --- a/.github/tests/fixtures/pull_request_template.md +++ /dev/null @@ -1,226 +0,0 @@ -{{ One or two short summary paragraphs that state what changes, why, who is -affected, and required adoption actions or an explicit no-action result. }} - - - -## Breaking: {{ Reader-facing result }} - -{{ Explain what changes, why, affected supported uses, compatibility, and exact -adoption actions or an explicit no-action result. Include applicable -prerequisites, verification, ordering, and any required intermediate installation -or run. }} - -{{ Interface or old-to-new example, comparison, or diagram after the prose when -needed to make adoption unambiguous. Keep required transition details in this -note; link to evergreen usage docs only for optional depth. Delete when prose -is sufficient. }} - -
-Technical details - -{{ Implementation and validation evidence, closing delivery-leaf plan progress, -and issue-convergence search scope and outcome for this result, even when no -additional issue converges. }} - -| Changed surface | Standards checked | Framework docs checked | Result | -| --- | --- | --- | --- | -| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | - -{{ For an applicable release-managed result, identify the consumer-change release -and source baseline and evidence supporting the actions or no-action result -above. For an affected integration template, identify its repository, verified -compatible immutable commit, and compatibility evidence. Publication resolves -actual release coordinates; do not predict a final version. Delete inapplicable -evidence. }} - -
- -
-Related references - -- Resolves MSXOrg/repository#123 -- References MSXOrg/repository#456 -- Depends on MSXOrg/other-repository#789 - -
- -## Removed: {{ Reader-facing result }} - -{{ Explain what changes, why, affected supported uses, compatibility, and exact -adoption actions or an explicit no-action result. Include applicable -prerequisites, verification, ordering, and any required intermediate installation -or run. }} - -{{ Interface or old-to-new example, comparison, or diagram after the prose when -needed to make adoption unambiguous. Keep required transition details in this -note; link to evergreen usage docs only for optional depth. Delete when prose -is sufficient. }} - -
-Technical details - -{{ Implementation and validation evidence, closing delivery-leaf plan progress, -and issue-convergence search scope and outcome for this result, even when no -additional issue converges. }} - -| Changed surface | Standards checked | Framework docs checked | Result | -| --- | --- | --- | --- | -| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | - -{{ For an applicable release-managed result, identify the consumer-change release -and source baseline and evidence supporting the actions or no-action result -above. For an affected integration template, identify its repository, verified -compatible immutable commit, and compatibility evidence. Publication resolves -actual release coordinates; do not predict a final version. Delete inapplicable -evidence. }} - -
- -
-Related references - -- Resolves MSXOrg/repository#123 -- References MSXOrg/repository#456 -- Depends on MSXOrg/other-repository#789 - -
- -## New: {{ Reader-facing result }} - -{{ Explain what changes, why, affected supported uses, compatibility, and exact -adoption actions or an explicit no-action result. Include applicable -prerequisites, verification, ordering, and any required intermediate installation -or run. }} - -{{ Interface or old-to-new example, comparison, or diagram after the prose when -needed to make adoption unambiguous. Keep required transition details in this -note; link to evergreen usage docs only for optional depth. Delete when prose -is sufficient. }} - -
-Technical details - -{{ Implementation and validation evidence, closing delivery-leaf plan progress, -and issue-convergence search scope and outcome for this result, even when no -additional issue converges. }} - -| Changed surface | Standards checked | Framework docs checked | Result | -| --- | --- | --- | --- | -| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | - -{{ For an applicable release-managed result, identify the consumer-change release -and source baseline and evidence supporting the actions or no-action result -above. For an affected integration template, identify its repository, verified -compatible immutable commit, and compatibility evidence. Publication resolves -actual release coordinates; do not predict a final version. Delete inapplicable -evidence. }} - -
- -
-Related references - -- Resolves MSXOrg/repository#123 -- References MSXOrg/repository#456 -- Depends on MSXOrg/other-repository#789 - -
- -## Changed: {{ Reader-facing result }} - -{{ Explain what changes, why, affected supported uses, compatibility, and exact -adoption actions or an explicit no-action result. Include applicable -prerequisites, verification, ordering, and any required intermediate installation -or run. }} - -{{ Interface or old-to-new example, comparison, or diagram after the prose when -needed to make adoption unambiguous. Keep required transition details in this -note; link to evergreen usage docs only for optional depth. Delete when prose -is sufficient. }} - -
-Technical details - -{{ Implementation and validation evidence, closing delivery-leaf plan progress, -and issue-convergence search scope and outcome for this result, even when no -additional issue converges. }} - -| Changed surface | Standards checked | Framework docs checked | Result | -| --- | --- | --- | --- | -| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | - -{{ For an applicable release-managed result, identify the consumer-change release -and source baseline and evidence supporting the actions or no-action result -above. For an affected integration template, identify its repository, verified -compatible immutable commit, and compatibility evidence. Publication resolves -actual release coordinates; do not predict a final version. Delete inapplicable -evidence. }} - -
- -
-Related references - -- Resolves MSXOrg/repository#123 -- References MSXOrg/repository#456 -- Depends on MSXOrg/other-repository#789 - -
- -## Fixed: {{ Reader-facing result }} - -{{ Explain what changes, why, affected supported uses, compatibility, and exact -adoption actions or an explicit no-action result. Include applicable -prerequisites, verification, ordering, and any required intermediate installation -or run. }} - -{{ Interface or old-to-new example, comparison, or diagram after the prose when -needed to make adoption unambiguous. Keep required transition details in this -note; link to evergreen usage docs only for optional depth. Delete when prose -is sufficient. }} - -
-Technical details - -{{ Implementation and validation evidence, closing delivery-leaf plan progress, -and issue-convergence search scope and outcome for this result, even when no -additional issue converges. }} - -| Changed surface | Standards checked | Framework docs checked | Result | -| --- | --- | --- | --- | -| `{{ Path or interface }}` | {{ Applicable standards }} | {{ Applicable framework docs, or `None (no framework-specific docs)` }} | {{ `Aligned`, `Fixed in this PR`, or `Exception - MSXOrg/repository#123` }} | - -{{ For an applicable release-managed result, identify the consumer-change release -and source baseline and evidence supporting the actions or no-action result -above. For an affected integration template, identify its repository, verified -compatible immutable commit, and compatibility evidence. Publication resolves -actual release coordinates; do not predict a final version. Delete inapplicable -evidence. }} - -
- -
-Related references - -- Resolves MSXOrg/repository#123 -- References MSXOrg/repository#456 -- Depends on MSXOrg/other-repository#789 - -
- -
-Release decisions - -- **Release:** {{ Yes or No }} - {{ Evidence from the artifact, route, and - supported consumer contract. }} -- **Version bump:** {{ Patch, Minor, or Major }} - {{ Compatibility evidence. - Identify the explicit label, supported caller input, repository setting, or - documented shared default that supplies it. Delete this field when Release - is No. }} -- **Mode:** {{ Stable or Prerelease }} - {{ Target-route or override evidence. - Identify configured release-candidate routing when applicable. Delete this - field when Release is No. }} -- **Labels:** {{ Exact `release:*` labels, or `None` when effective configuration - supplies the decision without an override label. }} - -
diff --git a/.github/validation.psd1 b/.github/validation.psd1 index 950fd27..88ef792 100644 --- a/.github/validation.psd1 +++ b/.github/validation.psd1 @@ -1,6 +1,12 @@ @{ SourceRepository = 'MSXOrg/docs' - SourceCommit = '1ececb2bd9f1680c87cec15a45ac927a554fce9c' - SourcePath = 'src/docs/Ways-of-Working/PR-Format.md' - TemplateSha256 = '94fd209828f902b5faf4af9248826078843ea1ce37f168049606b53cfb6802c4' + 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 index a220119..b429069 100644 --- a/.github/workflows/Organization-Defaults-Validation.yml +++ b/.github/workflows/Organization-Defaults-Validation.yml @@ -3,6 +3,7 @@ name: Organization defaults validation 'on': pull_request: + types: [opened, synchronize, reopened, edited] branches: - main @@ -37,13 +38,14 @@ jobs: ./.github/scripts/Test-OrganizationDefaults.ps1 -BaseRevision $env:BASE_SHA -ExpectedHead $env:PR_HEAD_SHA - - name: Test valid and malformed fallback fixtures + - name: Test no-template inputs and validation regressions shell: pwsh run: >- ./.github/scripts/Invoke-PesterSuite.ps1 -Path ./.github/tests -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 @@ -53,7 +55,8 @@ jobs: USE_FIND_ALGORITHM: true STRIP_DEFAULT_WORKSPACE_FOR_REGEX: true FILTER_REGEX_INCLUDE: ${{ steps.inputs.outputs.files_regex }} - VALIDATE_MARKDOWN: true + # Markdown already used literal content and no inline overrides. + VALIDATE_MARKDOWN: false VALIDATE_POWERSHELL: true VALIDATE_YAML: true VALIDATE_GITHUB_ACTIONS: true From 14675e8f28a3427a88dcac6b0329bc1e61fcc301 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:32:28 +0100 Subject: [PATCH 11/16] Use supported include-only shared-linter settings --- .github/tests/WorkflowTriggers.Tests.ps1 | 6 ++++++ .github/workflows/Organization-Defaults-Validation.yml | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/tests/WorkflowTriggers.Tests.ps1 b/.github/tests/WorkflowTriggers.Tests.ps1 index c4e0ea5..a59af31 100644 --- a/.github/tests/WorkflowTriggers.Tests.ps1 +++ b/.github/tests/WorkflowTriggers.Tests.ps1 @@ -49,4 +49,10 @@ Describe 'Pull request validation trigger coverage' { $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/workflows/Organization-Defaults-Validation.yml b/.github/workflows/Organization-Defaults-Validation.yml index b429069..e31965c 100644 --- a/.github/workflows/Organization-Defaults-Validation.yml +++ b/.github/workflows/Organization-Defaults-Validation.yml @@ -55,8 +55,7 @@ jobs: USE_FIND_ALGORITHM: true STRIP_DEFAULT_WORKSPACE_FOR_REGEX: true FILTER_REGEX_INCLUDE: ${{ steps.inputs.outputs.files_regex }} - # Markdown already used literal content and no inline overrides. - VALIDATE_MARKDOWN: false + # Only these routes are enabled; Markdown was checked by the controller. VALIDATE_POWERSHELL: true VALIDATE_YAML: true VALIDATE_GITHUB_ACTIONS: true From 56869a47e2769ab28032908ef974326963eb9449 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:37:54 +0100 Subject: [PATCH 12/16] Reject PowerShell paths that can lint an unchanged file --- .github/VALIDATION.md | 2 ++ .github/scripts/PullRequestValidation.psm1 | 6 ++++++ .github/tests/PullRequestValidation.Tests.ps1 | 14 ++++++++++++++ .../workflows/Organization-Defaults-Validation.yml | 2 +- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index 16d8536..a8be6e2 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -66,6 +66,8 @@ 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 diff --git a/.github/scripts/PullRequestValidation.psm1 b/.github/scripts/PullRequestValidation.psm1 index d27b615..f108b86 100644 --- a/.github/scripts/PullRequestValidation.psm1 +++ b/.github/scripts/PullRequestValidation.psm1 @@ -239,6 +239,12 @@ function Get-ValidationInput { $existing = @($relevant | Where-Object { Test-Path -LiteralPath (Join-Path -Path $repository -ChildPath $_) -PathType Leaf }) + 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.' + } $deleted = @($relevant | Where-Object { $_ -notin $existing }) if ($relevant.Count -eq 0) { throw 'No applicable changed input was found. Fixture self-tests are not a complete pull request validation pass.' diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index ce42354..46832dd 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -102,6 +102,20 @@ Describe 'Actual committed inputs without a PR template' { '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. diff --git a/.github/workflows/Organization-Defaults-Validation.yml b/.github/workflows/Organization-Defaults-Validation.yml index e31965c..4f192c1 100644 --- a/.github/workflows/Organization-Defaults-Validation.yml +++ b/.github/workflows/Organization-Defaults-Validation.yml @@ -55,7 +55,7 @@ jobs: USE_FIND_ALGORITHM: true STRIP_DEFAULT_WORKSPACE_FOR_REGEX: true FILTER_REGEX_INCLUDE: ${{ steps.inputs.outputs.files_regex }} - # Only these routes are enabled; Markdown was checked by the controller. + # The controller already checked Markdown; only these routes run. VALIDATE_POWERSHELL: true VALIDATE_YAML: true VALIDATE_GITHUB_ACTIONS: true From ef29ef62af710dd327ac9ce7d7f72006aeb0d964 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 22:48:59 +0100 Subject: [PATCH 13/16] Discover the source test suite with the native runner defaults --- .github/VALIDATION.md | 12 ++++++++++-- .github/scripts/Invoke-PesterSuite.ps1 | 4 ++-- .github/tests/PullRequestValidation.Tests.ps1 | 18 ++++++++++++++++++ .../Organization-Defaults-Validation.yml | 2 +- 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index a8be6e2..68b450f 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -22,13 +22,19 @@ 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 -Path .github\tests -RequiredVersion 6.1.0 + pwsh -NoProfile -File .github\scripts\Invoke-PesterSuite.ps1 -RequiredVersion 6.1.0 ``` 3. Validate the actual committed inputs, shared sources, and changed Markdown: @@ -57,7 +63,8 @@ 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. +from the same immutable shared tooling revision, with its local default adapted +to `.github/tests` so invocation without `-Path` discovers this repository's suite. The workflow uses `pull_request` activities `opened`, `synchronize`, `reopened`, and `edited`, so retargeting a PR to `main` starts validation without a new head @@ -90,6 +97,7 @@ not a rule or access change performed by this code. | 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 diff --git a/.github/scripts/Invoke-PesterSuite.ps1 b/.github/scripts/Invoke-PesterSuite.ps1 index dd8f903..bb4da2d 100644 --- a/.github/scripts/Invoke-PesterSuite.ps1 +++ b/.github/scripts/Invoke-PesterSuite.ps1 @@ -22,7 +22,7 @@ .EXAMPLE ./Invoke-PesterSuite.ps1 - Runs every suite under tests/ and exits non-zero if any test fails. + Runs every suite under .github/tests/ and exits non-zero if any test fails. .EXAMPLE ./Invoke-PesterSuite.ps1 -Path ./tests -RequiredVersion 6.0.1 @@ -46,7 +46,7 @@ param( # Directory holding the Pester suites. Every '*.Tests.ps1' beneath it is run. [Parameter()] - [string] $Path = (Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'tests'), + [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. diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index 46832dd..36df63b 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -274,3 +274,21 @@ Describe 'Source controller accepts canonical-guidance-only inputs' { $LASTEXITCODE | Should -Be 0 } } + +Describe 'Repository-native test runner defaults' { + It 'discovers the source test directory without an explicit Path override' { + $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 + [IO.File]::WriteAllText( + (Join-Path -Path $tests -ChildPath 'Anonymous.Tests.ps1'), + "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' + } +} diff --git a/.github/workflows/Organization-Defaults-Validation.yml b/.github/workflows/Organization-Defaults-Validation.yml index 4f192c1..dfafbd5 100644 --- a/.github/workflows/Organization-Defaults-Validation.yml +++ b/.github/workflows/Organization-Defaults-Validation.yml @@ -42,7 +42,7 @@ jobs: shell: pwsh run: >- ./.github/scripts/Invoke-PesterSuite.ps1 - -Path ./.github/tests -RequiredVersion 6.1.0 + -RequiredVersion 6.1.0 - name: Lint the applicable changed files if: steps.inputs.outputs.existing_files != '0' From c5b38970ee1858905e56419392f16e1ba9e906d8 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 23:03:35 +0100 Subject: [PATCH 14/16] Track case-only deletions from the committed diff --- .github/VALIDATION.md | 2 ++ .github/scripts/PullRequestValidation.psm1 | 15 +++++++++++---- .github/tests/PullRequestValidation.Tests.ps1 | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index 68b450f..ac27138 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -51,6 +51,8 @@ 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. Every selected Markdown file is read literally and sent on stdin to markdownlint-cli2 with the [unchanged shared rules][markdown]. diff --git a/.github/scripts/PullRequestValidation.psm1 b/.github/scripts/PullRequestValidation.psm1 index f108b86..6eb1952 100644 --- a/.github/scripts/PullRequestValidation.psm1 +++ b/.github/scripts/PullRequestValidation.psm1 @@ -236,16 +236,23 @@ function Get-ValidationInput { if (-not [string]::IsNullOrEmpty(($status -join "`n"))) { throw 'The working tree differs from HEAD. Commit the intended changes before certifying that head.' } - $existing = @($relevant | Where-Object { - Test-Path -LiteralPath (Join-Path -Path $repository -ChildPath $_) -PathType Leaf - }) + $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.' } - $deleted = @($relevant | Where-Object { $_ -notin $existing }) if ($relevant.Count -eq 0) { throw 'No applicable changed input was found. Fixture self-tests are not a complete pull request validation pass.' } diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index 36df63b..d8c716a 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -78,6 +78,24 @@ Describe 'Actual committed inputs without a PR template' { $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*' From 9809ca1c0f3334ee53c19029b6e50bf4f5e48ff5 Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 23:16:29 +0100 Subject: [PATCH 15/16] Reject test runs with no passed regressions --- .github/VALIDATION.md | 5 ++++- .github/scripts/Invoke-PesterSuite.ps1 | 9 ++++++--- .github/tests/PullRequestValidation.Tests.ps1 | 18 ++++++++++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index ac27138..c1ebb84 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -53,6 +53,8 @@ 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. Every selected Markdown file is read literally and sent on stdin to markdownlint-cli2 with the [unchanged shared rules][markdown]. @@ -66,7 +68,8 @@ 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` so invocation without `-Path` discovers this repository's suite. +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 diff --git a/.github/scripts/Invoke-PesterSuite.ps1 b/.github/scripts/Invoke-PesterSuite.ps1 index bb4da2d..9c6219f 100644 --- a/.github/scripts/Invoke-PesterSuite.ps1 +++ b/.github/scripts/Invoke-PesterSuite.ps1 @@ -12,9 +12,8 @@ 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 — a run that silently discovers nothing is green and - worthless, so it is treated as a failure. It exits 0 only when every discovered test - passed, which is what makes it usable as a merge gate. + 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 @@ -212,6 +211,10 @@ 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 ($result.Result -eq 'Passed' -and $failedSuite -eq 0) { 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 diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index d8c716a..75dca7f 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -294,19 +294,33 @@ Describe 'Source controller accepts canonical-guidance-only inputs' { } Describe 'Repository-native test runner defaults' { - It 'discovers the source test directory without an explicit Path override' { + 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( - (Join-Path -Path $tests -ChildPath 'Anonymous.Tests.ps1'), + $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' } }" + ) + $output = pwsh -NoProfile -File $runner -RequiredVersion 6.1.0 + $LASTEXITCODE | Should -Be 1 + ($output -join "`n") | Should -Match 'No test passed' + } } From a6c4484fa197f44bf45e244e17480cb40deafd3e Mon Sep 17 00:00:00 2001 From: Marius Storhaug Date: Sat, 31 Oct 2026 23:29:05 +0100 Subject: [PATCH 16/16] Use one Pester success decision for summaries and exit codes --- .github/VALIDATION.md | 1 + .github/scripts/Invoke-PesterSuite.ps1 | 40 ++++++++++++++++++- .github/tests/PullRequestValidation.Tests.ps1 | 15 ++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/.github/VALIDATION.md b/.github/VALIDATION.md index c1ebb84..460bee6 100644 --- a/.github/VALIDATION.md +++ b/.github/VALIDATION.md @@ -55,6 +55,7 @@ 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]. diff --git a/.github/scripts/Invoke-PesterSuite.ps1 b/.github/scripts/Invoke-PesterSuite.ps1 index 9c6219f..f7c17af 100644 --- a/.github/scripts/Invoke-PesterSuite.ps1 +++ b/.github/scripts/Invoke-PesterSuite.ps1 @@ -99,6 +99,42 @@ function Write-WorkflowAnnotation { 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 @@ -126,7 +162,7 @@ function Get-PesterSummaryMarkdown { [Parameter(Mandatory)] [string] $SuiteRoot ) - $verdict = if ($Result.Result -eq 'Passed') { '✅' } else { '❌' } + $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('') @@ -215,7 +251,7 @@ 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 ($result.Result -eq 'Passed' -and $failedSuite -eq 0) { +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 } diff --git a/.github/tests/PullRequestValidation.Tests.ps1 b/.github/tests/PullRequestValidation.Tests.ps1 index 75dca7f..4033ed8 100644 --- a/.github/tests/PullRequestValidation.Tests.ps1 +++ b/.github/tests/PullRequestValidation.Tests.ps1 @@ -319,8 +319,19 @@ Describe 'Repository-native test runner defaults' { $testPath, "Describe 'Anonymous skipped suite' { It 'does not run' -Skip { throw 'must not run' } }" ) - $output = pwsh -NoProfile -File $runner -RequiredVersion 6.1.0 - $LASTEXITCODE | Should -Be 1 + $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') } }