From 2c85a50838b5d24e8c724f92267f1ac5bf62113e Mon Sep 17 00:00:00 2001 From: Michiel Oda Date: Wed, 29 Jul 2026 09:07:17 +0200 Subject: [PATCH] Add except-change-detector to see if a RN/Task is needed for the PR --- .../actions/exempt-change-detector/README.md | 59 ++++++++++ .../actions/exempt-change-detector/action.yml | 37 ++++++ .../exempt-change-detector.ps1 | 110 ++++++++++++++++++ .../actions/exempt-change-detector/test.ps1 | 83 +++++++++++++ .github/actions/references-parser/README.md | 5 +- .github/actions/references-parser/action.yml | 19 ++- .../references-parser/references-parser.ps1 | 13 ++- .github/actions/references-parser/test.ps1 | 20 ++++ 8 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 .github/actions/exempt-change-detector/README.md create mode 100644 .github/actions/exempt-change-detector/action.yml create mode 100644 .github/actions/exempt-change-detector/exempt-change-detector.ps1 create mode 100644 .github/actions/exempt-change-detector/test.ps1 diff --git a/.github/actions/exempt-change-detector/README.md b/.github/actions/exempt-change-detector/README.md new file mode 100644 index 0000000..d5ccc13 --- /dev/null +++ b/.github/actions/exempt-change-detector/README.md @@ -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 }} +``` diff --git a/.github/actions/exempt-change-detector/action.yml b/.github/actions/exempt-change-detector/action.yml new file mode 100644 index 0000000..1c5fd71 --- /dev/null +++ b/.github/actions/exempt-change-detector/action.yml @@ -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"' diff --git a/.github/actions/exempt-change-detector/exempt-change-detector.ps1 b/.github/actions/exempt-change-detector/exempt-change-detector.ps1 new file mode 100644 index 0000000..4045fd7 --- /dev/null +++ b/.github/actions/exempt-change-detector/exempt-change-detector.ps1 @@ -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}" + } +} diff --git a/.github/actions/exempt-change-detector/test.ps1 b/.github/actions/exempt-change-detector/test.ps1 new file mode 100644 index 0000000..50543d4 --- /dev/null +++ b/.github/actions/exempt-change-detector/test.ps1 @@ -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 +} diff --git a/.github/actions/references-parser/README.md b/.github/actions/references-parser/README.md index 9ace1ed..0203dc8 100644 --- a/.github/actions/references-parser/README.md +++ b/.github/actions/references-parser/README.md @@ -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 @@ -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 diff --git a/.github/actions/references-parser/action.yml b/.github/actions/references-parser/action.yml index f941df8..2eaf42a 100644 --- a/.github/actions/references-parser/action.yml +++ b/.github/actions/references-parser/action.yml @@ -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: @@ -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"' \ No newline at end of file diff --git a/.github/actions/references-parser/references-parser.ps1 b/.github/actions/references-parser/references-parser.ps1 index 0af3c68..04dcf7b 100644 --- a/.github/actions/references-parser/references-parser.ps1 +++ b/.github/actions/references-parser/references-parser.ps1 @@ -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() @@ -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) { @@ -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.' } } diff --git a/.github/actions/references-parser/test.ps1 b/.github/actions/references-parser/test.ps1 index b731e12..6a3432f 100644 --- a/.github/actions/references-parser/test.ps1 +++ b/.github/actions/references-parser/test.ps1 @@ -55,6 +55,8 @@ function Invoke-ParserCase { [Parameter(Mandatory)][string]$ExpectedChangeType, [Parameter(Mandatory)][string]$ExpectedRnsJson, [Parameter(Mandatory)][string]$ExpectedTasksJson, + [string]$Exempt = 'false', + [switch]$UseBodyFile, [string[]]$ExpectedCommentContains = @(), [string[]]$ExpectedCommentMissing = @() ) @@ -67,8 +69,16 @@ function Invoke-ParserCase { $summaryFile = Join-Path -Path $caseDirectory -ChildPath 'summary.md' $env:PR_BODY = $Body + $env:PR_BODY_FILE = '' + if ($UseBodyFile) { + $bodyFile = Join-Path -Path $caseDirectory -ChildPath 'body.md' + Set-Content -Path $bodyFile -Value $Body -Encoding utf8 -NoNewline + $env:PR_BODY = 'THIS SHOULD BE IGNORED' + $env:PR_BODY_FILE = $bodyFile + } $env:ACTOR = $Actor $env:LABELS_JSON = $LabelsJson + $env:EXEMPT = $Exempt $env:COMMENT_HEADER = 'skyline-references' $env:GITHUB_OUTPUT = $outputFile $env:GITHUB_STEP_SUMMARY = $summaryFile @@ -160,6 +170,16 @@ try { '- [DCP35](https://collaboration.dataminer.services/task/35)' ) + # Exempt-mode cases (workflow/unit-test only PRs relax the task requirement to RN-only). + Invoke-ParserCase -Name '21 exempt rn only' -Body "References: [RN46090]" -Actor 'human' -LabelsJson '[]' -Exempt 'true' -ExpectedStatus 'passed' -ExpectedChangeType 'Patch' -ExpectedRnsJson '["RN46090"]' -ExpectedTasksJson '[]' + Invoke-ParserCase -Name '22 exempt rn and task' -Body "References: [RN12] [DCP35]" -Actor 'human' -LabelsJson '[]' -Exempt 'true' -ExpectedStatus 'passed' -ExpectedChangeType 'Patch' -ExpectedRnsJson '["RN12"]' -ExpectedTasksJson '["DCP35"]' + Invoke-ParserCase -Name '23 exempt still requires rn' -Body "References: [DCP35]" -Actor 'human' -LabelsJson '[]' -Exempt 'true' -ExpectedStatus 'failed' -ExpectedChangeType '-' -ExpectedRnsJson '-' -ExpectedTasksJson '-' + Invoke-ParserCase -Name '24 non-exempt still requires task' -Body "References: [RN12]" -Actor 'human' -LabelsJson '[]' -Exempt 'false' -ExpectedStatus 'failed' -ExpectedChangeType '-' -ExpectedRnsJson '-' -ExpectedTasksJson '-' + Invoke-ParserCase -Name '25 exempt comment row' -Body "References: [RN46090]" -Actor 'human' -LabelsJson '[]' -Exempt 'true' -ExpectedStatus 'passed' -ExpectedChangeType 'Patch' -ExpectedRnsJson '["RN46090"]' -ExpectedTasksJson '[]' -ExpectedCommentContains @( + '| Exempt change (workflow/tests) — task not required | Yes |' + ) -ExpectedCommentMissing @('**Tasks**') + Invoke-ParserCase -Name '26 body file overrides pr-body' -Body "References: [RN12] [DCP35]" -Actor 'human' -LabelsJson '[]' -UseBodyFile -ExpectedStatus 'passed' -ExpectedChangeType 'Patch' -ExpectedRnsJson '["RN12"]' -ExpectedTasksJson '["DCP35"]' + Write-Output 'All references-parser cases passed.' } finally { Remove-Item -Path $temporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue