Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/verify-closing-keywords.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Verify Closing Keywords

# Fails a pull request whose body or commit messages list issue references after ONE
# closing keyword -- `Fixes #100, #101` closes #100 only. GitHub reads closing keywords AT
# MERGE TIME, so editing a merged PR's body fixes nothing; this is the only point at which
# the mistake can be caught. tools/Test-PfbClosingKeywords.ps1 holds the rule and its scope
# notes. To quote the wrong form on purpose, put it in backticks.
#
# It does NOT read the PR title or comments, and it does not check that a referenced issue
# exists or is the right one -- only that every reference meant to close has its own keyword.
#
# ubuntu-latest because a PR body can be 65,536 characters and Windows caps an environment
# variable at 32,767. The body reaches the script as an environment VALUE, never as text
# built into a command line.
#
# COMMITS: those on the PR head that are not merges and not already on the base branch.
# `--not origin/<base>` as well as the merge-base, because pull_request.base.sha can be
# stale: after the branch is updated from main, merge-base(stale base, head) is the stale
# base, and main's own commits since then would otherwise be read as the PR's.

on:
pull_request:
types: [opened, edited, synchronize, reopened]

permissions:
contents: read

concurrency:
group: verify-closing-keywords-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
name: Each issue has its own closing keyword
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Check the PR body and commit messages
shell: pwsh
env:
PR_BODY: ${{ github.event.pull_request.body }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
$ErrorActionPreference = 'Stop'
$check = './tools/Test-PfbClosingKeywords.ps1'
$failures = 0
# Workflow-command values must escape %, CR and LF, or text from the PR could
# shape the annotation.
function Format-Annotation { param([string]$s) ($s -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A') }

foreach ($f in @(& $check -Text $env:PR_BODY)) {
$failures++
Write-Host ("::error::PR body, line {0}: '{1}' closes only its first reference. Write: {2}" -f $f.Line, (Format-Annotation $f.Fragment), (Format-Annotation $f.Corrected))
}

$mergeBase = ([string](git merge-base $env:BASE_SHA $env:HEAD_SHA)).Trim()
if ($LASTEXITCODE -ne 0 -or -not $mergeBase) { throw 'git merge-base failed' }
$exclude = @($mergeBase)
git rev-parse --verify --quiet "origin/$($env:BASE_REF)" | Out-Null
if ($LASTEXITCODE -eq 0) { $exclude += "origin/$($env:BASE_REF)" }
else { Write-Host "::warning::origin/$($env:BASE_REF) is not in the checkout; reading commits from the merge-base only." }

$commits = @(git rev-list --no-merges $env:HEAD_SHA --not @exclude)
if ($LASTEXITCODE -ne 0) { throw 'git rev-list failed' }
foreach ($sha in $commits) {
$message = (git log -1 --format=%B $sha) -join "`n"
foreach ($f in @(& $check -Text $message)) {
$failures++
Write-Host ("::error::Commit {0}, line {1}: '{2}' closes only its first reference. Write: {3}" -f $sha.Substring(0, 10), $f.Line, (Format-Annotation $f.Fragment), (Format-Annotation $f.Corrected))
}
}

Write-Host "Checked the PR body and $($commits.Count) commit message(s): $failures finding(s)."
if ($failures -gt 0) { exit 1 }
74 changes: 74 additions & 0 deletions .github/workflows/verify-wire-exemption.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: Verify Wire Exemption

# INFORMATIONAL ONLY. Classifies this pull request's diff with
# tools/Test-PfbWireExemption.ps1 -- can it change a request the module sends or a response
# it parses? -- and writes the verdict to the job summary.
#
# NOTHING MAY GATE ON THIS JOB. A pull_request run executes the workflow file from the PR's
# own merge commit, so a PR that edits this file controls what it prints. The job therefore
# never fails on the verdict: NotExempt still exits 0, Undecided is a warning. It fails only
# when it malfunctions. A gate that needs the verdict recomputes it outside the PR's control
# (see "TRUSTING THE VERDICT" in the script's help).
#
# It runs the BASE revision's copy of the classifier, never the PR's. The classifier only
# reads git blobs and tokenises them; it never executes code from the diff.
#
# On the PR that introduces the classifier the base has no copy yet, so the job says so and
# skips.

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: read

concurrency:
group: verify-wire-exemption-${{ github.ref }}
cancel-in-progress: true

jobs:
classify:
name: Classify the diff (informational)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out the repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Classify with the base revision's classifier
shell: pwsh
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
$ErrorActionPreference = 'Stop'
git cat-file -e "$($env:BASE_SHA):tools/Test-PfbWireExemption.ps1" 2>$null
if ($LASTEXITCODE -ne 0) {
Write-Host '::notice::The base revision has no tools/Test-PfbWireExemption.ps1 (for example, the PR that adds it), so there is no trusted copy to run. Skipped.'
exit 0
}
$copy = Join-Path $env:RUNNER_TEMP 'Test-PfbWireExemption.ps1'
git show "$($env:BASE_SHA):tools/Test-PfbWireExemption.ps1" > $copy
if ($LASTEXITCODE -ne 0) { throw "git show failed with exit $LASTEXITCODE" }

$verdict = @(& $copy -RepoPath $env:GITHUB_WORKSPACE -BaseRef $env:BASE_SHA -HeadRef $env:HEAD_SHA)[-1]
$code = $LASTEXITCODE
if ($null -eq $verdict -or -not $verdict.PSObject.Properties['Decision']) { throw "The classifier returned no verdict (exit $code)." }

$lines = @('## Wire exemption (informational)', '', "Decision: **$($verdict.Decision)** (exit $code)", '')
if ($verdict.Decision -eq 'Undecided' -and $verdict.Reason) { $lines += "Reason: $($verdict.Reason)"; $lines += '' }
if (@($verdict.Files).Count -gt 0) {
$lines += '| File | Verdict | First executable line |'
$lines += '|---|---|---|'
foreach ($f in $verdict.Files) { $lines += ('| {0} | {1} | {2} |' -f ($f.Path -replace '\|', '\|'), $f.Verdict, $f.FirstExecutableLine) }
$lines += ''
}
if ($verdict.Basis) { $lines += "Basis: $($verdict.Basis)"; $lines += '' }
$lines += 'This job is informational. Nothing gates on its result.'
[System.IO.File]::AppendAllText($env:GITHUB_STEP_SUMMARY, ($lines -join "`n") + "`n")

if ($verdict.Decision -eq 'Undecided') { Write-Host '::warning::The wire-exemption classifier could not decide (treat as not exempt). The reason is in the job summary.' }
exit 0
38 changes: 38 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Start here

The tool-neutral entry point for anyone -- person or agent -- changing this repository. The
detailed rules live in three files:

- [`CLAUDE.md`](CLAUDE.md) -- what the module is, when the derived reports must be
regenerated, and what to run before pushing.
- [`Tests/CLAUDE.md`](Tests/CLAUDE.md) -- writing and running the Pester tests, the
two-edition rule, and the coverage baseline in `Tests/coverage-baseline.psd1`.
- [`.github/workflows/CLAUDE.md`](.github/workflows/CLAUDE.md) -- authoring the GitHub
Actions workflows.

## What CI checks on a pull request

| Workflow | What it checks | Fails the PR? |
|---|---|---|
| `cross-platform-tests.yml` | Pester on Windows PowerShell 5.1 and on PowerShell 7 (Windows, Linux, macOS), the coverage baseline, and PSScriptAnalyzer | Yes |
| `verify-derived-artifacts.yml` | Every committed artifact in `Data/` and `Reports/` matches a regeneration from the branch (runs when an input changes) | Yes |
| `verify-workflows.yml` | actionlint over `.github/workflows/` (runs when `.github/` changes) | Yes |
| `verify-closing-keywords.yml` | Every issue the PR body or a commit references after a closing keyword has its own keyword | Yes |
| `verify-wire-exemption.yml` | Whether the diff can change a request the module sends or a response it parses | No -- informational |

"Fails the PR" means the check goes red. None is a required status check; merging is the
maintainer's decision.

Scheduled, not on pull requests: `update-api-capability-map.yml`,
`verify-agent-ready-briefs.yml` and `report-action-pins.yml`.

## Scripts you can run locally

Under PowerShell 7 (`pwsh`); only the module itself has to run on Windows PowerShell 5.1.

- `scripts/Assert-PfbDerivedArtifacts.ps1` -- the derived-artifact check CI runs.
- `tools/Test-PfbWireExemption.ps1 -BaseRef origin/main` -- whether your branch can change
what goes on the wire. Exit 0 exempt, 1 not, 2 undecided. Prints a basis line for the PR
body when exempt.
- `tools/Test-PfbClosingKeywords.ps1 -Text <your PR body>` -- the closing-keyword check, with
the corrected form for each finding.
145 changes: 145 additions & 0 deletions Tests/Test-PfbClosingKeywords.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0' }
<#
.SYNOPSIS
tools/Test-PfbClosingKeywords.ps1: every reference a closing keyword does NOT close is found,
and nothing GitHub would not close from is flagged.
.DESCRIPTION
UNGATED on edition: the script is 5.1-safe and runs on both legs. Listed in
RequiredDescribes for both editions.
#>

BeforeAll {
$script:repoRoot = Split-Path -Parent $PSScriptRoot
$script:check = Join-Path (Join-Path $script:repoRoot 'tools') 'Test-PfbClosingKeywords.ps1'
# The unary comma is load-bearing: without it a single finding is unrolled on return,
# and on Windows PowerShell 5.1 a lone pscustomobject has no .Count (it reads $null).
function Get-TestFinding { param([AllowNull()][string]$Text) return , @(& $script:check -Text $Text) }
}

Describe 'Test-PfbClosingKeywords: the PR #108 incident' {
It 'flags the incident text and hands back the corrected form' {
$f = Get-TestFinding 'Fixes #100, #101, #103, #87, and #80.'
$f.Count | Should -Be 1
$f[0].Line | Should -Be 1
$f[0].Keyword | Should -BeExactly 'Fixes'
$f[0].Fragment | Should -BeExactly 'Fixes #100, #101, #103, #87, and #80'
$f[0].Corrected | Should -BeExactly 'Fixes #100, fixes #101, fixes #103, fixes #87, fixes #80'
@($f[0].Missed) -join ',' | Should -BeExactly '#101,#103,#87,#80'
}
It 'passes the corrected form' {
(Get-TestFinding 'Fixes #100, fixes #101, fixes #103, fixes #87, fixes #80.').Count | Should -Be 0
}
It 'reports the right line in a CRLF body (GitHub stores web-edited bodies with CRLF)' {
$f = Get-TestFinding "## Summary`r`n`r`nFixes #100, #101, #103, #87, and #80.`r`n"
$f.Count | Should -Be 1
$f[0].Line | Should -Be 3
}
}

Describe 'Test-PfbClosingKeywords: what is flagged' {
It 'flags <Text>' -ForEach @(
@{ Text = 'Fixes #100, #101'; Corrected = 'Fixes #100, fixes #101' }
@{ Text = 'fixes #87, #88'; Corrected = 'fixes #87, fixes #88' }
@{ Text = 'Closes #1 and dmann000/fb-powershell#2'; Corrected = 'Closes #1, closes dmann000/fb-powershell#2' }
@{ Text = 'Resolves #5, #6'; Corrected = 'Resolves #5, resolves #6' }
@{ Text = 'Fixes: #1, #2'; Corrected = 'Fixes #1, fixes #2' }
@{ Text = 'Fixes https://github.com/dmann000/fb-powershell/issues/1, #2'; Corrected = 'Fixes https://github.com/dmann000/fb-powershell/issues/1, fixes #2' }
@{ Text = 'Fixes #1; #2 / #3 & #4 + #5 plus #6'; Corrected = 'Fixes #1, fixes #2, fixes #3, fixes #4, fixes #5, fixes #6' }
@{ Text = "Fixes #1,$([char]0x00A0)#2"; Corrected = 'Fixes #1, fixes #2' }
) {
$f = Get-TestFinding $Text
$f.Count | Should -Be 1
$f[0].Corrected | Should -BeExactly $Corrected
}
It 'knows all nine keywords in any case: <Kw>' -ForEach @(
'close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved',
'CLOSES', 'Fixed', 'ReSoLvEd' | ForEach-Object { @{ Kw = $_ } }
) {
(Get-TestFinding "$Kw #1, #2").Count | Should -Be 1
}
It 'reports two chains in one text, each on its own line' {
$f = Get-TestFinding "Fixes #1, #2`nand later`nCloses #3, #4"
@($f | ForEach-Object Line) -join ',' | Should -BeExactly '1,3'
}
It 'joins pipeline input into ONE text, so a fence split across lines is still a fence' {
@('```', 'Fixes #1, #2', '```' | & $script:check).Count | Should -Be 0
@('intro', 'Fixes #1, #2' | & $script:check)[0].Line | Should -Be 2
}
}

Describe 'Test-PfbClosingKeywords: what is not flagged' {
It 'passes <Why>' -ForEach @(
@{ Why = 'a single reference'; Text = 'Fixes #63' }
@{ Why = 'prose between references'; Text = 'Fixes #100. Related: #101, #102' }
@{ Why = 'a conventional-commit subject'; Text = 'fix(admin): stop the dead query keys (#99, #100)' }
@{ Why = 'a code span'; Text = 'The old body read `Fixes #100, #101` and closed only #100.' }
@{ Why = 'a ``` fence'; Text = "Fixes #63.`n`n``````n Fixes #1, #2`n``````n" }
@{ Why = 'a ~~~ fence'; Text = "~~~`nFixes #1, #2`n~~~" }
@{ Why = 'an HTML comment (the PR template''s own example)'; Text = "<!--`nFixes #1, #2`n-->" }
@{ Why = 'a keyword AFTER the list'; Text = 'See #1, #2 -- this fixes them' }
@{ Why = 'a keyword at the end of one line and references on the next'; Text = "this is fixed`n#1, #2" }
@{ Why = 'a keyword that is only the tail of a longer word'; Text = 'hotfixes #1, #2' }
@{ Why = 'an empty text'; Text = '' }
) {
(Get-TestFinding $Text).Count | Should -Be 0
}
It 'accepts $null without throwing' {
{ Get-TestFinding $null } | Should -Not -Throw
(Get-TestFinding $null).Count | Should -Be 0
}
It 'handles a 65,536-character body quickly (no catastrophic backtracking)' {
$body = ('x ' * 32760) + 'Fixes #1, #2'
$body.Length | Should -BeGreaterOrEqual 65000
$elapsed = Measure-Command { $script:big = Get-TestFinding $body }
$script:big.Count | Should -Be 1
$elapsed.TotalSeconds | Should -BeLessThan 5
}
}

Describe 'Test-PfbClosingKeywords: letters are ASCII, as in the JavaScript original' {
# The local hook is JavaScript, whose /i flag (without /u) folds only within ASCII for these
# letters. .NET IgnoreCase does not: on pwsh 7 it treats the Kelvin sign (U+212A) as a K, so
# [^A-Za-z0-9_] stops matching it and the keyword after it is missed -- while Windows
# PowerShell 5.1 agrees with JavaScript. The script spells case out in explicit classes so
# both editions and the hook give one answer.
It 'still sees a keyword right after a Kelvin sign' {
(Get-TestFinding "$([char]0x212A)fixes #1, #2").Count | Should -Be 1
}
It 'does not read a long s (U+017F) as an s' {
(Get-TestFinding "clo$([char]0x017F)es #1, #2").Count | Should -Be 0
(Get-TestFinding "Fixes #1 plu$([char]0x017F) #2").Count | Should -Be 0
}
}

Describe 'verify-closing-keywords.yml' {
BeforeAll {
$script:ck = [System.IO.File]::ReadAllText((Join-Path $script:repoRoot '.github/workflows/verify-closing-keywords.yml'))
$script:ckRun = @([regex]::Matches($script:ck, '(?m)^([ \t]+)(?:- )?run: \|[ \t]*\r?\n((?:(?:\1[ \t]+\S[^\r\n]*|[ \t]*)(?:\r?\n|$))+)') | ForEach-Object { $_.Groups[2].Value })
}
It 'runs on opened, edited, synchronize and reopened, on ubuntu (a body can exceed the Windows env-var cap)' {
$script:ck | Should -Match '(?m)^ types: \[opened, edited, synchronize, reopened\]\s*$'
$script:ck | Should -Match '(?m)^ runs-on: ubuntu-latest\s*$'
}
It 'reads contents only, with no secret' {
$permissions = [regex]::Match($script:ck, '(?ms)^permissions:[ \t]*\r?\n(.*?)(?=^\S)').Groups[1].Value
@([regex]::Matches($permissions, '(?m)^ ([a-z-]+: \S+)') | ForEach-Object { $_.Groups[1].Value }) -join ',' | Should -BeExactly 'contents: read'
$script:ck | Should -Not -Match 'secrets\.'
}
It 'passes the body as an env VALUE and interpolates nothing into a run block' {
$script:ck | Should -Match ([regex]::Escape('PR_BODY: ${{ github.event.pull_request.body }}'))
$script:ck | Should -Match ([regex]::Escape('-Text $env:PR_BODY'))
$script:ckRun.Count | Should -BeGreaterThan 0
@($script:ckRun | Where-Object { $_.Contains('${{') }).Count | Should -Be 0
}
It 'reads commits from a full-depth checkout, without merges, excluding what is already on the base branch' {
$script:ck | Should -Match '(?m)^\s+fetch-depth: 0\s*$'
$script:ck | Should -Match ([regex]::Escape('git merge-base $env:BASE_SHA $env:HEAD_SHA'))
$script:ck | Should -Match 'git rev-list --no-merges \$env:HEAD_SHA --not'
$script:ck | Should -Match ([regex]::Escape('"origin/$($env:BASE_REF)"'))
}
It 'annotates each finding with the bare ::error:: form and fails the job' {
$script:ck | Should -Match '::error::'
$script:ck | Should -Not -Match '::error file='
$script:ck | Should -Match '(?m)^\s+if \(\$failures -gt 0\) \{ exit 1 \}'
}
}
Loading
Loading