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