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
59 changes: 59 additions & 0 deletions .github/actions/exempt-change-detector/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# exempt-change-detector

Classifies a pull request as an **exempt change** when *every* changed file matches one of the
configured glob patterns. Exempt PRs (by default those touching only GitHub configuration or unit-test
projects) do not require a task id and can have a predefined RN auto-added by the caller workflow, so
the mandatory PR-validation gate and the commit-metadata ruleset stay satisfied without manual RN/Task
administration.

## Inputs

| Input | Required | Description |
| --- | --- | --- |
| `changed-files` | yes | Newline-separated list of file paths changed in the pull request. |
| `patterns` | no | Newline-separated glob patterns. Defaults to `.github/**`, `**/*Tests/**`, `**/*.Tests/**`. |

## Outputs

| Output | Description |
| --- | --- |
| `exempt` | `true` when at least one file changed and every changed file matches a pattern; otherwise `false`. |

## Rules

- A PR is exempt only when **at least one** file changed **and every** changed file matches a pattern.
- An **empty** change set is never exempt (so it can never trigger the auto-RN injection).
- Patterns support `*` (any run of non-separator characters), `?` (one non-separator character), and
`**` (any characters including `/`). `**/` matches zero or more leading path segments.
- Matching is case-insensitive.

## Glob semantics

| Pattern | Matches | Does not match |
| --- | --- | --- |
| `.github/**` | `.github/workflows/ci.yml`, `.github/dependabot.yml` | `docs/.github/x` |
| `**/*Tests/**` | `src/MyProjectTests/Foo.cs`, `src/My.Tests/Foo.cs` | `FooTests.cs` (no folder) |
| `**/*.Tests/**` | `src/My.Tests/Foo.cs` | `src/Testing/Foo.cs` |

## Usage

```yaml
- name: Collect changed files
id: changed
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
files="$(gh api --paginate "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files" --jq '.[].filename')"
{
echo "files<<__FILES_EOF__"
echo "$files"
echo "__FILES_EOF__"
} >> "$GITHUB_OUTPUT"

- name: Classify exempt change
id: exempt
uses: SkylineCommunications/_ReusableWorkflows/.github/actions/exempt-change-detector@main
with:
changed-files: ${{ steps.changed.outputs.files }}
```
37 changes: 37 additions & 0 deletions .github/actions/exempt-change-detector/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Exempt Change Detector
description: >
Classifies a pull request as an "exempt change" when every changed file matches one of the
configured glob patterns (by default GitHub configuration and unit-test projects). Exempt PRs
are allowed to omit the task id and can have a predefined RN auto-added by the caller workflow.

inputs:
changed-files:
description: Newline-separated list of file paths changed in the pull request.
required: true
patterns:
description: >
Newline-separated glob patterns. A pull request is exempt only when every changed file matches
at least one pattern. Supports '*', '?', and '**' (including '**/' for zero or more segments).
required: false
default: |
.github/**
**/*Tests/**
**/*.Tests/**

outputs:
exempt:
description: >
'true' when at least one file changed and every changed file matches a pattern; otherwise 'false'.
An empty change set is never exempt.
value: ${{ steps.classify.outputs.exempt }}

runs:
using: composite
steps:
- name: Classify changed files
id: classify
shell: pwsh
env:
CHANGED_FILES: ${{ inputs.changed-files }}
PATTERNS: ${{ inputs.patterns }}
run: '& "$env:GITHUB_ACTION_PATH/exempt-change-detector.ps1"'
110 changes: 110 additions & 0 deletions .github/actions/exempt-change-detector/exempt-change-detector.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
$ErrorActionPreference = 'Stop'

if ([string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) {
throw 'GITHUB_OUTPUT must be set.'
}

$changedFilesRaw = if ($null -ne $env:CHANGED_FILES) { $env:CHANGED_FILES } else { '' }
$patternsRaw = if ($null -ne $env:PATTERNS) { $env:PATTERNS } else { '' }

function ConvertTo-Lines {
param([AllowNull()][string]$Value)

if ([string]::IsNullOrEmpty($Value)) {
return @()
}

$lines = [System.Text.RegularExpressions.Regex]::Split($Value, "`r?`n")
return @($lines | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}

function Convert-GlobToRegex {
param([Parameter(Mandatory)][string]$Glob)

$builder = [System.Text.StringBuilder]::new()
$null = $builder.Append('^')

$index = 0
$length = $Glob.Length
while ($index -lt $length) {
$character = $Glob[$index]
switch ($character) {
'*' {
$isDoubleStar = ($index + 1 -lt $length) -and ($Glob[$index + 1] -eq '*')
if ($isDoubleStar) {
$followedBySlash = ($index + 2 -lt $length) -and ($Glob[$index + 2] -eq '/')
if ($followedBySlash) {
# '**/' matches zero or more leading path segments.
$null = $builder.Append('(?:.*/)?')
$index += 3
} else {
# '**' matches anything, including path separators.
$null = $builder.Append('.*')
$index += 2
}
} else {
# '*' matches anything except a path separator.
$null = $builder.Append('[^/]*')
$index += 1
}
}
'?' {
$null = $builder.Append('[^/]')
$index += 1
}
default {
$null = $builder.Append([System.Text.RegularExpressions.Regex]::Escape([string]$character))
$index += 1
}
}
}

$null = $builder.Append('$')
return $builder.ToString()
}

$patterns = ConvertTo-Lines -Value $patternsRaw
if ($patterns.Count -eq 0) {
throw 'At least one exempt pattern must be provided via the patterns input.'
}

$patternRegexes = @($patterns | ForEach-Object { Convert-GlobToRegex -Glob $_ })

$changedFiles = ConvertTo-Lines -Value $changedFilesRaw

# A change is only exempt when at least one file changed and every changed file matches a pattern.
# An empty file list is never exempt, so it can never trigger the auto-RN injection.
$exempt = $changedFiles.Count -gt 0
$nonMatching = [System.Collections.Generic.List[string]]::new()

foreach ($file in $changedFiles) {
$matched = $false
foreach ($regex in $patternRegexes) {
if ([System.Text.RegularExpressions.Regex]::IsMatch($file, $regex, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)) {
$matched = $true
break
}
}

if (-not $matched) {
$exempt = $false
$nonMatching.Add($file)
}
}

$exemptValue = if ($exempt) { 'true' } else { 'false' }

@(
"exempt=${exemptValue}"
) | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8

if ($changedFiles.Count -eq 0) {
Write-Output 'No changed files detected; not exempt.'
} elseif ($exempt) {
Write-Output "All $($changedFiles.Count) changed file(s) match an exempt pattern; exempt=true."
} else {
Write-Output "Not exempt; $($nonMatching.Count) changed file(s) outside exempt patterns:"
foreach ($file in $nonMatching) {
Write-Output " - ${file}"
}
}
83 changes: 83 additions & 0 deletions .github/actions/exempt-change-detector/test.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
$ErrorActionPreference = 'Stop'

$actionDirectory = Split-Path -Parent $PSCommandPath
$temporaryDirectory = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "exempt-change-detector-$([System.Guid]::NewGuid())"
New-Item -Path $temporaryDirectory -ItemType Directory | Out-Null

function Get-OutputValue {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Path
)

$prefix = "${Name}="
$line = Get-Content -Path $Path | Where-Object { $_.StartsWith($prefix, [System.StringComparison]::Ordinal) } | Select-Object -Last 1
if ($null -eq $line) {
return ''
}

return $line.Substring($prefix.Length)
}

function Assert-Equal {
param(
[AllowNull()][object]$Actual,
[AllowNull()][object]$Expected,
[Parameter(Mandatory)][string]$Label
)

if ($Actual -ne $Expected) {
throw "${Label}: expected '${Expected}', got '${Actual}'."
}
}

# Default patterns mirror the action.yml default so tests exercise the real configuration.
$defaultPatterns = @(
'.github/**'
'**/*Tests/**'
'**/*.Tests/**'
) -join "`n"

function Invoke-DetectorCase {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][AllowEmptyString()][string]$ChangedFiles,
[Parameter(Mandatory)][string]$ExpectedExempt,
[string]$Patterns = $defaultPatterns
)

$caseDirectoryName = $Name -replace '[^A-Za-z0-9]', '_'
$caseDirectory = Join-Path -Path $temporaryDirectory -ChildPath $caseDirectoryName
New-Item -Path $caseDirectory -ItemType Directory -Force | Out-Null
$outputFile = Join-Path -Path $caseDirectory -ChildPath 'outputs.txt'

$env:CHANGED_FILES = $ChangedFiles
$env:PATTERNS = $Patterns
$env:GITHUB_OUTPUT = $outputFile

& (Join-Path -Path $actionDirectory -ChildPath 'exempt-change-detector.ps1') | Out-Null

$exempt = Get-OutputValue -Name 'exempt' -Path $outputFile
Assert-Equal -Actual $exempt -Expected $ExpectedExempt -Label "${Name} exempt"
}

try {
Invoke-DetectorCase -Name '01 workflow only' -ChangedFiles ".github/workflows/ci.yml" -ExpectedExempt 'true'
Invoke-DetectorCase -Name '02 github config file' -ChangedFiles ".github/dependabot.yml" -ExpectedExempt 'true'
Invoke-DetectorCase -Name '03 github nested action' -ChangedFiles ".github/actions/foo/action.yml" -ExpectedExempt 'true'
Invoke-DetectorCase -Name '04 unit test project folder' -ChangedFiles "src/MyProject.Tests/FooTests.cs" -ExpectedExempt 'true'
Invoke-DetectorCase -Name '05 unit test project suffix' -ChangedFiles "src/MyProjectTests/FooTests.cs" -ExpectedExempt 'true'
Invoke-DetectorCase -Name '06 all exempt mixed' -ChangedFiles ".github/workflows/ci.yml`nsrc/MyProject.Tests/FooTests.cs" -ExpectedExempt 'true'
Invoke-DetectorCase -Name '07 source code not exempt' -ChangedFiles "src/MyProject/Foo.cs" -ExpectedExempt 'false'
Invoke-DetectorCase -Name '08 mixed source and exempt' -ChangedFiles ".github/workflows/ci.yml`nsrc/MyProject/Foo.cs" -ExpectedExempt 'false'
Invoke-DetectorCase -Name '09 empty change set' -ChangedFiles "" -ExpectedExempt 'false'
Invoke-DetectorCase -Name '10 blank lines only' -ChangedFiles "`n `n" -ExpectedExempt 'false'
Invoke-DetectorCase -Name '11 case insensitive tests' -ChangedFiles "src/MyProject.tests/footests.cs" -ExpectedExempt 'true'
Invoke-DetectorCase -Name '12 test file at repo root not matched by folder pattern' -ChangedFiles "FooTests.cs" -ExpectedExempt 'false'
Invoke-DetectorCase -Name '13 non-test cs under tests-like name' -ChangedFiles "src/Testing/Foo.cs" -ExpectedExempt 'false'
Invoke-DetectorCase -Name '14 docs not exempt by default' -ChangedFiles "README.md" -ExpectedExempt 'false'

Write-Output 'All exempt-change-detector cases passed.'
} finally {
Remove-Item -Path $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue
}
5 changes: 4 additions & 1 deletion .github/actions/references-parser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ The action finds the `References:` line (e.g. `References: [RN44205] [DCP284603]

| Input | Required | Description |
| --- | --- | --- |
| `pr-body` | yes | Pull request description markdown. |
| `pr-body` | no | Pull request description markdown. Ignored when `pr-body-file` is set and the file exists. |
| `pr-body-file` | no | Path to a file containing the PR description. Overrides `pr-body` when set. Used to parse a freshly fetched (possibly auto-edited) body. |
| `actor` | yes | Pull request author login. Only `dependabot[bot]` may omit task ids. |
| `labels-json` | yes | JSON array of PR label names. Used to resolve `Change-Type`. |
| `exempt` | no | When `'true'`, the change is workflow/unit-test only and the task id is not required (an RN is still required). Defaults to `'false'`. |

## Outputs

Expand All @@ -31,6 +33,7 @@ The action finds the `References:` line (e.g. `References: [RN44205] [DCP284603]
- Ids may appear in any order; duplicates are de-duplicated.
- Regular PRs require at least one RN and at least one task.
- `dependabot[bot]` PRs require an RN only; a human on a `dependabot/*` branch is not exempt.
- Exempt PRs (`exempt: 'true'`, set by the caller for workflow/unit-test only changes) require an RN only.
- At most one `Change-Type:*` label is allowed. Valid values are `Patch`, `Minor`, and `Major`.

## Usage
Expand Down
19 changes: 17 additions & 2 deletions .github/actions/references-parser/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,27 @@ description: >

inputs:
pr-body:
description: The pull request description markdown.
required: true
description: The pull request description markdown. Ignored when pr-body-file is set and exists.
required: false
default: ''
pr-body-file:
description: >
Optional path to a file containing the pull request description. When set and the file exists,
its contents override pr-body. Used to parse a freshly fetched (possibly auto-edited) body.
required: false
default: ''
actor:
description: The pull request author login. Used for the dependabot[bot] task exception.
required: true
labels-json:
description: JSON array of pull request label names. Used to resolve Change-Type.
required: true
exempt:
description: >
When 'true', the change is exempt (workflow or unit-test only) and no task id is required
(an RN id is still required). Any other value keeps the standard task requirement.
required: false
default: 'false'

outputs:
status:
Expand All @@ -40,7 +53,9 @@ runs:
shell: pwsh
env:
PR_BODY: ${{ inputs.pr-body }}
PR_BODY_FILE: ${{ inputs.pr-body-file }}
ACTOR: ${{ inputs.actor }}
LABELS_JSON: ${{ inputs.labels-json }}
EXEMPT: ${{ inputs.exempt }}
COMMENT_HEADER: skyline-references
run: '& "$env:GITHUB_ACTION_PATH/references-parser.ps1"'
13 changes: 11 additions & 2 deletions .github/actions/references-parser/references-parser.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ if ([string]::IsNullOrWhiteSpace($env:GITHUB_WORKSPACE)) {
throw 'GITHUB_WORKSPACE must be set.'
}

$prBody = if ($null -ne $env:PR_BODY) { $env:PR_BODY } else { '' }
$prBody = ''
if (-not [string]::IsNullOrEmpty($env:PR_BODY_FILE) -and (Test-Path -LiteralPath $env:PR_BODY_FILE -PathType Leaf)) {
$prBody = Get-Content -LiteralPath $env:PR_BODY_FILE -Raw
} elseif ($null -ne $env:PR_BODY) {
$prBody = $env:PR_BODY
}
$actor = if ($null -ne $env:ACTOR) { $env:ACTOR } else { '' }
$labelsJson = if ($null -ne $env:LABELS_JSON) { $env:LABELS_JSON } else { '[]' }
$commentHeader = if ($null -ne $env:COMMENT_HEADER) { $env:COMMENT_HEADER } else { 'skyline-references' }
$isExempt = ($env:EXEMPT -eq 'true')

$errors = [System.Collections.Generic.List[string]]::new()
$allRns = [System.Collections.Generic.List[string]]::new()
Expand Down Expand Up @@ -166,6 +172,9 @@ function Write-ValidationComment {
if ($isDependabot) {
$content.Add('| Dependabot RN-only mode | Yes |')
}
if ($script:isExempt) {
$content.Add('| Exempt change (workflow/tests) — task not required | Yes |')
}
$content.Add('')

if ($script:errors.Count -gt 0) {
Expand Down Expand Up @@ -217,7 +226,7 @@ if ($referencesFound) {
Add-ValidationError -Message 'At least one RN id is required.'
}

if ($actor -ne 'dependabot[bot]' -and $tasks.Count -eq 0) {
if ($actor -ne 'dependabot[bot]' -and -not $isExempt -and $tasks.Count -eq 0) {
Add-ValidationError -Message 'At least one task id is required for non-Dependabot PRs.'
}
}
Expand Down
Loading
Loading