diff --git a/.github/actions/ci-versioning/action.yml b/.github/actions/ci-versioning/action.yml index b454836..a7ecddd 100644 --- a/.github/actions/ci-versioning/action.yml +++ b/.github/actions/ci-versioning/action.yml @@ -557,6 +557,47 @@ runs: } Write-Host "::notice title=Versioning::Found versioning datasets for: $($versions.Name -join ', ')" + # Precondition, not a policy choice. The runner is handed + # --subject-assemblies "\Build" and restricts attribution to the + # namespaces the assemblies there declare. If that directory is absent it falls + # back to attributing every failure across the whole dependency closure, which + # reports other repositories' defects against this one: measured at 1,056 of 1,056 + # on a real pull request. The runner does warn, but on stderr, and a warning does + # not stop the check producing a verdict it has no basis for. + # + # Asserted here rather than left to the runner because this is a property of the + # build, and the build is this action's job. Same shape as the dataset guard above. + # + # Not the vacuous-green question. "Nothing to check" is a legitimate green; + # "the thing to check was never built" is a broken precondition. This is the second. + # + # Two causes when it fires, and they need different fixes. Either the projects declare + # ..\Build\ only inside PropertyGroups conditioned on Debug or Test, so a + # Release build does not apply it; or they declare no at all and take the + # SDK default. Both send output to bin\\\ and leave Build\ absent. + # + # Neither is a fault in those repositories. Nothing ever verified this directory: the + # convention was enforced by a linter that only rewrote lines already + # present, and no check read Build\ until this one. + - name: Validate subject build output + if: steps.changed.outputs.count != '0' + shell: pwsh + run: | + $subjectDir = "${{ github.workspace }}\Build" + + if (-not (Test-Path $subjectDir)) { + Write-Host "::error title=Versioning::Subject build output missing at $subjectDir. This repository's own assemblies were not built to the directory the check attributes against, so no failure could be attributed to it. Two usual causes: the projects declare ..\Build\ only under Debug or Test conditions, which a Release build does not apply; or they declare no at all and take the SDK default. Either way the output went to bin\Release\\ instead." + exit 1 + } + + $dlls = @(Get-ChildItem $subjectDir -Filter *.dll -Recurse -ErrorAction SilentlyContinue) + if ($dlls.Count -eq 0) { + Write-Host "::error title=Versioning::Subject build output at $subjectDir contains no assemblies. The directory exists but nothing was built into it, so no failure could be attributed to this repository." + exit 1 + } + + Write-Host "::notice title=Versioning::Subject build output: $($dlls.Count) assembl(ies) in $subjectDir." + - name: Prepare VersioningRunner id: runner if: steps.changed.outputs.count != '0' diff --git a/.github/scripts/tests/ci-versioning-action.Tests.ps1 b/.github/scripts/tests/ci-versioning-action.Tests.ps1 new file mode 100644 index 0000000..460f5ca --- /dev/null +++ b/.github/scripts/tests/ci-versioning-action.Tests.ps1 @@ -0,0 +1,64 @@ +# ci-versioning-action.Tests.ps1 — structural assertions over ci-versioning's action.yml. +# +# Same shape as the sibling files for ci-serialisation and ci-compliance: properties of the +# action as a whole that a reader cannot check from any one place in it. +# +# The property here is that the check asserts its own preconditions. The runner is handed a +# subject build directory and narrows attribution to the assemblies in it; if that directory is +# missing it silently widens to the whole dependency closure and reports other repositories' +# defects against this one. The runner warns, but on stderr, and a warning does not stop a +# verdict being produced on no basis. +# +# Run locally: pwsh -Command "Invoke-Pester .github/scripts/tests -Output Detailed" +# Run in CI: lint-workflows.yml, the powershell-tests job. + +BeforeAll { + $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path + $script:actionPath = Join-Path $repoRoot '.github/actions/ci-versioning/action.yml' + $script:lines = Get-Content $actionPath + $script:text = $lines -join "`n" +} + +Describe 'ci-versioning action.yml' { + + Context 'preconditions are asserted before the runner is invoked' { + + # The datasets guard already existed and is the pattern being followed. Asserted so + # that if it is ever removed, the reasoning below stops resting on something absent. + It 'still guards the datasets directory' { + $text | Should -Match 'Datasets directory missing at' + } + + It 'guards the subject build output the same way' { + $text | Should -Match 'Subject build output missing at' -Because 'an absent subject directory makes the check attribute every failure across the whole closure' + } + + It 'also rejects a subject directory that exists but is empty' { + $text | Should -Match 'contains no assemblies' -Because 'a present but empty directory yields an empty subject set, which reports nothing and passes' + } + + It 'fails rather than warns, because the check cannot do its job without it' { + $guard = $text -split '- name: Validate subject build output' | Select-Object -Last 1 + $guard = ($guard -split '- name: ')[0] + $guard | Should -Match 'exit 1' + $guard | Should -Not -Match '::warning' + } + + # Ordering matters and is not obvious from either step alone: the guard is worthless + # after the thing it protects has already run. + It 'runs before the versioning tests, not after' { + $guardIdx = ($lines | Select-String -Pattern '- name: Validate subject build output' | Select-Object -First 1).LineNumber + $runnerIdx = ($lines | Select-String -Pattern '- name: Run versioning tests' | Select-Object -First 1).LineNumber + $guardIdx | Should -BeLessThan $runnerIdx + } + + # Substring rather than regex: the path contains backslashes and braces, and a guard + # that checked a different directory from the one the runner reads would pass a + # loosely-written pattern while protecting nothing. + It 'checks the same directory the runner is pointed at' { + $subjectPath = '"${{ github.workspace }}\Build"' + $text.Contains('$subjectDir = ' + $subjectPath) | Should -BeTrue -Because 'the guard must read the path the runner is given' + $text.Contains('--subject-assemblies ' + $subjectPath) | Should -BeTrue -Because 'if the runner argument changes, this guard stops protecting it' + } + } +}