From 104215acf5366ac9e5edd5aaadf069c780df96a3 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:35:04 -0500 Subject: [PATCH 1/9] Add nightly dogfood feed staleness banner to release-readiness trackers Each release-readiness tracker now opens with a banner reflecting how fresh the nightly dogfood feed for THAT lane is, so a release captain can see at a glance whether dogfooders are validating current bits or testing stale builds. - New shared helper scripts/NightlyFeed.ps1: - Get-NightlyFeedFreshness: queries the lane's Azure Artifacts feed (dotnet10, dotnet11, ...) and returns the newest build whose version matches a band prefix, by catalog publish date (the feed orders by version, NOT date, and mixes build families, so freshness must come from catalogEntry.published). Network call is FAIL-OPEN (any error -> null) and accepts an injectable -Fetcher so it is unit-tested offline. - Format-NightlyFeedBanner: PURE, deterministic renderer (caller passes -Now). Tiers: fresh (<3d), aging (3-6d), stale (>=7d), plus muted unknown / no-match. - SR engine (Get-ReleaseReadiness.ps1): in full ('all') runs, maps the SR lane to dotnet + the .0. band (in-flight SR -> SR branch band; candidate -> main band), queries freshness, and renders the banner under the **Generated** line. Band-number matching is resilient to family-keyword churn (an SR8 .80 build is tagged ci.main, not ci.inflight). - Preview engine (Get-PreviewReadiness.ps1): maps the preview to dotnet + the .0.0-preview. band (iteration read from Versions.props at the survey ref) and renders the banner under **Overall status**. - Both engines defensively dot-source the helper (missing/unloadable helper degrades to no banner rather than crashing the unattended tracker job). - Tests: 26 offline assertions for the helper (tiers, unknown/no-match, future-clamp, custom thresholds, deterministic date; mocked-fetcher coverage of date-not-version selection, band filtering, fail-open, paged registration) plus SR render-path wiring assertions. Offline suite 592/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Get-PreviewReadiness.ps1 | 49 ++++ .../scripts/Get-ReleaseReadiness.ps1 | 82 +++++++ .../release-readiness/scripts/NightlyFeed.ps1 | 220 ++++++++++++++++++ .../tests/Test-ReleaseReadiness.ps1 | 129 ++++++++++ 4 files changed, 480 insertions(+) create mode 100644 .github/skills/release-readiness/scripts/NightlyFeed.ps1 diff --git a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 index dfa522ee0edc..49425d17b731 100644 --- a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 @@ -124,6 +124,19 @@ param( $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest +# Shared nightly-feed freshness helpers (Get-NightlyFeedFreshness / Format-NightlyFeedBanner). +# Defensive load: the banner is auxiliary signal, not part of the verdict, so a missing +# helper degrades to "no banner" rather than crashing the unattended preview tracker job. +# Loaded above the dot-source guard so the pure renderer is reachable from the test harness. +$Script:NightlyFeedHelperLoaded = $false +$nightlyFeedHelperPath = Join-Path $PSScriptRoot 'NightlyFeed.ps1' +if (Test-Path $nightlyFeedHelperPath) { + . $nightlyFeedHelperPath + $Script:NightlyFeedHelperLoaded = $true +} else { + Write-Warning "NightlyFeed.ps1 helper not found at $nightlyFeedHelperPath — nightly-feed banner disabled." +} + # =================================================================== # BRANCH PARSING # =================================================================== @@ -1346,6 +1359,38 @@ $report = [PSCustomObject]@{ PriorityIssues = $priorityIssues KnownBuildErrorIssues = $kbeIssues CiScanIssues = $ciScanIssues + NightlyFeed = $null +} + +# Nightly dogfood feed freshness (preview lane). Maps this preview to the dotnet +# Azure Artifacts feed + preview.N version band (from PreReleaseVersionIteration at the +# survey ref, falling back to the branch's preview number) and records how fresh the newest +# matching build is. Fail-open: any gap (helper unloaded, version unreadable, network error) +# degrades to "no banner". +$nightlyFeedBanner = $null +if ($Script:NightlyFeedHelperLoaded -and + (Get-Command Get-NightlyFeedFreshness -ErrorAction SilentlyContinue) -and + (Get-Command Format-NightlyFeedBanner -ErrorAction SilentlyContinue)) { + try { + $nfFeed = "dotnet$majorVersion" + $nfFeedUrl = "https://dev.azure.com/dnceng/public/_artifacts/feed/$nfFeed" + $nfIteration = Get-PreReleaseVersionIteration -BranchName $SurveyRef + if ([string]::IsNullOrWhiteSpace($nfIteration)) { $nfIteration = "$previewNumber" } + $nfBand = "$majorVersion.0.0-preview.$nfIteration" + $nfPrefix = '^' + [regex]::Escape("$nfBand.") + $nfLaneLabel = "[``$nfFeed``]($nfFeedUrl) · ``$nfBand`` (preview.$nfIteration)" + + $nfFresh = Get-NightlyFeedFreshness -Feed $nfFeed -VersionPrefixRegex $nfPrefix + if ($null -eq $nfFresh) { $nfFresh = @{ unknown = $true } } + $nfFresh['laneLabel'] = $nfLaneLabel + $nfFresh['feedUrl'] = $nfFeedUrl + $nfFresh['versionPrefix'] = $nfPrefix + + $report.NightlyFeed = $nfFresh + $nightlyFeedBanner = Format-NightlyFeedBanner -Freshness $nfFresh -Now ([DateTime]::UtcNow) + } catch { + Write-Warning "Nightly-feed freshness check failed (non-fatal): $($_.Exception.Message)" + } } $md = [System.Text.StringBuilder]::new() @@ -1360,6 +1405,10 @@ if ($Mode -eq 'candidate') { [void]$md.AppendLine("") [void]$md.AppendLine("**Overall status:** **$overallStatus**") [void]$md.AppendLine("") +if ($nightlyFeedBanner) { + [void]$md.AppendLine($nightlyFeedBanner) + [void]$md.AppendLine("") +} # === HIGH-PRIORITY ITEMS (hoisted to the very top) === # Four categories the release captain must see BEFORE anything else: diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index 56115765bb17..b89853439486 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -159,6 +159,18 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest +# Shared nightly-feed freshness helpers (Get-NightlyFeedFreshness / Format-NightlyFeedBanner). +# Defensive load: the banner is auxiliary signal, not part of the verdict, so a missing +# helper must degrade to "no banner" rather than crash the unattended nightly tracker job. +$Script:NightlyFeedHelperLoaded = $false +$nightlyFeedHelperPath = Join-Path $PSScriptRoot 'NightlyFeed.ps1' +if (Test-Path $nightlyFeedHelperPath) { + . $nightlyFeedHelperPath + $Script:NightlyFeedHelperLoaded = $true +} else { + Write-Warning "NightlyFeed.ps1 helper not found at $nightlyFeedHelperPath — nightly-feed banner disabled." +} + # DETERMINISTIC RULE — SR branches in dotnet/maui ALWAYS cut from `main`. # Refuse to operate on any `inflight/*` or `staging/*` ref — those are # integration branches, not SR sources. This guard exists because conflating @@ -3065,6 +3077,15 @@ function Format-MarkdownReport { $shaLinked = ConvertTo-LinkedSha -Sha $ctx.srHeadSha -RepoUrl $RepoUrl [void]$sb.AppendLine("**HEAD**: $shaLinked — $($ctx.srHeadSubject)") [void]$sb.AppendLine("**Generated**: $($ctx.fetchedAt)") + # Nightly dogfood feed freshness — surfaces when the feed testers point at has gone + # stale (no new build), so a captain sees at a glance whether dogfood feedback is being + # collected against current bits. The banner string is rendered upstream in Invoke-Main + # (where "now" is natural), keeping this renderer clock-free and deterministic. Absent in + # phase-scoped runs / when the helper isn't loaded → nothing is appended. + if ($Data.ContainsKey('nightlyFeedBanner') -and $Data['nightlyFeedBanner']) { + [void]$sb.AppendLine() + [void]$sb.AppendLine($Data['nightlyFeedBanner']) + } # Expected ship date — cadence depends on PatchVersion: # - x0 patches (80, 90…) + previews → 2nd Tuesday of the month # - hotfix patches (81, 82…) → ASAP, no cadence @@ -3590,6 +3611,60 @@ function Format-MarkdownReport { # region ────────────────────── 8. ORCHESTRATOR ──────────────────────────── +function Add-SrNightlyFeedFreshness { + <# + .SYNOPSIS + Maps this SR lane to its nightly Azure Artifacts dogfood feed + version band, + queries the freshest matching build, and stores both the structured result + ($Data['nightlyFeed']) and a pre-rendered banner string ($Data['nightlyFeedBanner']). + .DESCRIPTION + Lane → feed/band mapping (verified against the live feeds): + - feed = dotnet (e.g. dotnet10, dotnet11) + - band = .0. (PatchVersion from eng/Versions.props at srRef) + in-flight SR → srRef is the SR branch → inflight band (e.g. 10.0.80) + candidate SR → srRef is origin/main → main band (e.g. 10.0.90) + The band is matched as a literal version *prefix* (`^.0.-`), which is + resilient to family-keyword churn in the build metadata (ci.net10 → ci.main, etc.). + Fail-open throughout: any gap (helper not loaded, version unreadable, network error) + degrades to "no banner" rather than disturbing the verdict. + #> + param([hashtable]$Data) + + if (-not $Script:NightlyFeedHelperLoaded) { return } + if (-not (Get-Command Get-NightlyFeedFreshness -ErrorAction SilentlyContinue)) { return } + if (-not (Get-Command Format-NightlyFeedBanner -ErrorAction SilentlyContinue)) { return } + + try { + $ctx = $Data.metadata + $surveyRef = $ctx.srRef + $vp = Get-VersionsPropsState -Ref $surveyRef + if (-not $vp) { return } # can't map a band → skip silently (no banner) + + $major = [int]$vp.Major + $patch = [int]$vp.Patch + $band = "$major.0.$patch" + $feed = "dotnet$major" + $feedUrl = "https://dev.azure.com/dnceng/public/_artifacts/feed/$feed" + $prefix = '^' + [regex]::Escape($band) + '-' + + $mode = if ($ctx.ContainsKey('mode')) { $ctx['mode'] } else { 'in-flight' } + $familyWord = if ($mode -eq 'candidate') { 'main' } else { 'inflight' } + $laneLabel = "[``$feed``]($feedUrl) · ``$band`` ($familyWord)" + + $fresh = Get-NightlyFeedFreshness -Feed $feed -VersionPrefixRegex $prefix + if ($null -eq $fresh) { $fresh = @{ unknown = $true } } + $fresh['laneLabel'] = $laneLabel + $fresh['feedUrl'] = $feedUrl + $fresh['versionPrefix'] = $prefix + + $Data['nightlyFeed'] = $fresh + $banner = Format-NightlyFeedBanner -Freshness $fresh -Now ([DateTime]::UtcNow) + if ($banner) { $Data['nightlyFeedBanner'] = $banner } + } catch { + Write-Warning "Nightly-feed freshness check failed (non-fatal): $($_.Exception.Message)" + } +} + function Invoke-Main { $excludes = $ExcludeBranches -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } $ctx = Resolve-Context -SrBranch $SrBranch -Repo $Repo -MainBranch $MainBranch ` @@ -3624,6 +3699,13 @@ function Invoke-Main { warnings = @() } + # Nightly dogfood feed freshness (full runs only). Maps this SR lane to its Azure + # Artifacts feed + version band and records how fresh the newest matching build is, so + # the tracker can flag when dogfooders are testing stale bits. Fail-open inside. + if ($Phase -eq 'all') { + Add-SrNightlyFeedFreshness -Data $data + } + if ($Phase -in 'all', 'commits', 'regressions') { $srContents = Get-SrCommits -Ctx $ctx $data['srContents'] = $srContents diff --git a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 new file mode 100644 index 000000000000..790b21036252 --- /dev/null +++ b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 @@ -0,0 +1,220 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# +.SYNOPSIS + Nightly-feed freshness helpers shared by the SR and Preview release-readiness engines. + +.DESCRIPTION + Two functions: + + Get-NightlyFeedFreshness — queries an Azure Artifacts NuGet feed (e.g. dotnet10, + dotnet11) for the newest published build of a package + whose version matches a caller-supplied prefix regex, + and returns its version + publish date. Network call is + FAIL-OPEN: any error returns $null so a transient feed + outage never breaks tracker generation. + + Format-NightlyFeedBanner — PURE, deterministic renderer that turns a freshness + record into a one-line markdown banner (✅ fresh / + ⚠️ aging / ❌ stale / muted unknown). No network, no + clock access (caller passes -Now), so it is fully + unit-testable offline with fixtures. + + This file contains ONLY function/constant definitions (no top-level side effects), so + it is safe to dot-source from either engine or from the test harness. + + NOTE on feed semantics: the Azure Artifacts feed orders versions by version number, + NOT by date, and mixes several build families (e.g. dotnet10 carries ci.main, + ci.inflight and ci.net10). Freshness MUST therefore be derived from the catalog + `published` timestamps, scoped to the family the caller cares about via a version + prefix (band-based, e.g. `^10\.0\.90-` for main vs `^10\.0\.80-` for the SR8 inflight + band). This is resilient to the recurring family-keyword churn (ci.net9 → ci.net10 → + ci.main; c1.net11 → ci.net11 → preview.6). +#> + +Set-StrictMode -Version Latest + +# Default staleness tiers (days). A nightly build is expected every day, so anything +# beyond a couple of days is worth surfacing. +$Script:NightlyFeedAgingDays = 3 # >= this many days → ⚠️ aging +$Script:NightlyFeedStaleDays = 7 # >= this many days → ❌ stale + +# Safe property accessor: PSObject property access throws under Set-StrictMode when the +# property is absent. JSON shapes coming back from the feed vary (e.g. a registration +# page may inline `items` or only carry an `@id` to fetch), so every hop is guarded. +function Get-NightlyFeedProp { + param($Obj, [string]$Name) + if ($null -eq $Obj) { return $null } + if ($Obj -is [System.Collections.IDictionary]) { + if ($Obj.Contains($Name)) { return $Obj[$Name] } + return $null + } + if ($Obj.PSObject -and $Obj.PSObject.Properties[$Name]) { return $Obj.$Name } + return $null +} + +function ConvertTo-NightlyFeedUtc { + <# + .SYNOPSIS + Parse a feed catalog timestamp into a UTC [datetime], or $null if unparseable / + an unlisted-package sentinel (year 1900). + #> + param([string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + $dt = [datetime]::MinValue + $styles = [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor ` + [System.Globalization.DateTimeStyles]::AssumeUniversal + if ([datetime]::TryParse($Value, [System.Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$dt)) { + if ($dt.Year -lt 2000) { return $null } # NuGet uses 1900-01-01 for unlisted + return [datetime]::SpecifyKind($dt, [System.DateTimeKind]::Utc) + } + return $null +} + +function Get-NightlyFeedFreshness { + <# + .SYNOPSIS + Return the newest-by-publish-date build of $Package on Azure Artifacts feed $Feed + whose version matches $VersionPrefixRegex. + .OUTPUTS + On success : @{ feed; package; version; published=[datetime](UTC); matched=$true } + Queried, no match : @{ feed; package; matched=$false } + Hard failure : $null (network/parse error — caller renders "unknown") + .PARAMETER Fetcher + Optional scriptblock { param($Url) ... } returning parsed JSON. Lets tests inject + canned registration responses; defaults to Invoke-RestMethod. + #> + param( + [Parameter(Mandatory)][string]$Feed, + [string]$Package = 'Microsoft.Maui.Controls', + [string]$VersionPrefixRegex, + [int]$TimeoutSec = 20, + [scriptblock]$Fetcher + ) + + $get = if ($Fetcher) { + $Fetcher + } else { + { param($Url) Invoke-RestMethod -Uri $Url -TimeoutSec $TimeoutSec -ErrorAction Stop } + } + + try { + $pkgLower = $Package.ToLowerInvariant() + $serviceIndexUrl = "https://pkgs.dev.azure.com/dnceng/public/_packaging/$Feed/nuget/v3/index.json" + $index = & $get $serviceIndexUrl + $resources = Get-NightlyFeedProp $index 'resources' + if (-not $resources) { return $null } + + # Prefer the SemVer2 registration (3.6.0) so prerelease+metadata builds are listed. + $regBase = $null + $regFallback = $null + foreach ($r in $resources) { + $type = [string](Get-NightlyFeedProp $r '@type') + $id = [string](Get-NightlyFeedProp $r '@id') + if (-not $type.StartsWith('RegistrationsBaseUrl')) { continue } + $regFallback = $id + if ($type -match '3\.6\.0' -or $type -match 'Versioned') { $regBase = $id } + } + if (-not $regBase) { $regBase = $regFallback } + if (-not $regBase) { return $null } + + $regIndex = & $get ("{0}/{1}/index.json" -f $regBase.TrimEnd('/'), $pkgLower) + $pages = Get-NightlyFeedProp $regIndex 'items' + if (-not $pages) { return $null } + + $bestVersion = $null + $bestPublished = $null + foreach ($page in $pages) { + $leaves = Get-NightlyFeedProp $page 'items' + if (-not $leaves) { + $pageUrl = Get-NightlyFeedProp $page '@id' + if (-not $pageUrl) { continue } + $leaves = Get-NightlyFeedProp (& $get $pageUrl) 'items' + } + if (-not $leaves) { continue } + foreach ($leaf in $leaves) { + $ce = Get-NightlyFeedProp $leaf 'catalogEntry' + if (-not $ce) { continue } + $ver = [string](Get-NightlyFeedProp $ce 'version') + if ([string]::IsNullOrWhiteSpace($ver)) { continue } + if ($VersionPrefixRegex -and ($ver -notmatch $VersionPrefixRegex)) { continue } + $pub = ConvertTo-NightlyFeedUtc ([string](Get-NightlyFeedProp $ce 'published')) + if (-not $pub) { continue } + if ($null -eq $bestPublished -or $pub -gt $bestPublished) { + $bestPublished = $pub + $bestVersion = $ver + } + } + } + + if (-not $bestVersion) { + return @{ feed = $Feed; package = $Package; matched = $false } + } + return @{ + feed = $Feed + package = $Package + version = $bestVersion + published = $bestPublished + matched = $true + } + } catch { + return $null + } +} + +function Format-NightlyFeedBanner { + <# + .SYNOPSIS + Render a one-line markdown banner for a nightly-feed freshness record. PURE: output + depends only on the arguments (no network, no ambient clock). + .PARAMETER Freshness + Hashtable describing the feed lane. Required keys vary by case: + - $null → returns '' (caller opted not to render) + - @{ laneLabel; unknown=$true } → muted "freshness unknown" note + - @{ laneLabel; matched=$false } → muted "no matching build" note + - @{ laneLabel; version; published=[datetime] } → tiered ✅/⚠️/❌ banner + .PARAMETER Now + UTC reference time used to compute age. Caller supplies it so the function is + deterministic and testable. + #> + param( + $Freshness, + [datetime]$Now, + [int]$AgingDays = $Script:NightlyFeedAgingDays, + [int]$StaleDays = $Script:NightlyFeedStaleDays + ) + + if ($null -eq $Freshness) { return '' } + + $lane = [string](Get-NightlyFeedProp $Freshness 'laneLabel') + if ([string]::IsNullOrWhiteSpace($lane)) { $lane = 'nightly feed' } + + if (Get-NightlyFeedProp $Freshness 'unknown') { + return "_Nightly dogfood feed ($lane): freshness could not be determined this run (feed query failed)._" + } + if ($null -ne (Get-NightlyFeedProp $Freshness 'matched') -and -not (Get-NightlyFeedProp $Freshness 'matched')) { + return "_Nightly dogfood feed ($lane): no recent matching build found (build naming may have changed)._" + } + + $version = [string](Get-NightlyFeedProp $Freshness 'version') + $published = Get-NightlyFeedProp $Freshness 'published' + if ([string]::IsNullOrWhiteSpace($version) -or $null -eq $published) { return '' } + + $age = [int][Math]::Floor((($Now.ToUniversalTime()) - ([datetime]$published).ToUniversalTime()).TotalDays) + if ($age -lt 0) { $age = 0 } # clamp clock skew / just-published + $dateStr = ([datetime]$published).ToUniversalTime().ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) + + if ($age -ge $StaleDays) { + return "> ❌ **Nightly dogfood feed is STALE — $age days** ($lane). Latest build ``$version`` published $dateStr. A fresh nightly is expected daily; builds appear to have stopped, so dogfooders can't validate recent fixes — check the nightly pipeline." + } + if ($age -ge $AgingDays) { + return "> ⚠️ **Nightly dogfood feed is $age days old** ($lane). Latest build ``$version`` published $dateStr. A fresh nightly is expected daily." + } + + $whenPhrase = switch ($age) { + 0 { 'today' } + 1 { 'yesterday' } + default { "$age days ago" } + } + return "**Nightly dogfood feed:** ✅ $lane — latest ``$version`` built $whenPhrase." +} diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index e6767e10b721..63dc44913780 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -1617,6 +1617,22 @@ Assert-Eq -Label "Body has human-notes:begin marker" -Expected $true ` Assert-Eq -Label "Body has human-notes:end marker" -Expected $true ` -Actual ($md -match '') +# Nightly-feed banner wiring: when Invoke-Main has populated $Data['nightlyFeedBanner'], +# Format-MarkdownReport must render it just below the **Generated** line; when the key is +# absent (phase-scoped runs / helper unloaded) nothing leaks into the body. +Assert-Eq -Label "No nightly banner when key absent" -Expected $true ` + -Actual ($md -notmatch 'Nightly dogfood feed') +$mdDataBanner = $mdData.Clone() +$mdDataBanner['nightlyFeedBanner'] = '> ❌ **Nightly dogfood feed is STALE — 9 days** (test lane).' +$mdBan = Format-MarkdownReport -Data $mdDataBanner -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "Banner rendered when key present" -Expected $true ` + -Actual ($mdBan -match 'Nightly dogfood feed is STALE — 9 days') +$genIdx = $mdBan.IndexOf('**Generated**') +$banIdx = $mdBan.IndexOf('Nightly dogfood feed is STALE') +Assert-Eq -Label "Banner appears after the **Generated** line" -Expected $true ` + -Actual ($genIdx -ge 0 -and $banIdx -gt $genIdx) + # Without TrackerKey: no tracker marker, no visible Tracker line $mdNoTracker = Format-MarkdownReport -Data $mdData -RepoUrl 'https://github.com/dotnet/maui' ` -MaxBodyBytes 60000 @@ -3387,6 +3403,119 @@ Assert-Eq -Label "Format-MarkdownCell: literal backslash-pipe does NOT break out Assert-Eq -Label "Format-MarkdownCell: pre-existing NON-pipe backslash preserved (doubling is scoped to pipe-adjacent runs)" -Expected 'C:\dir' -Actual (Format-MarkdownCell 'C:\dir') Assert-Eq -Label "Format-MarkdownCell: author-escaped non-pipe Markdown NOT de-escaped" -Expected '\[link\](url)' -Actual (Format-MarkdownCell '\[link\](url)') +# ─────────── Nightly-feed freshness helpers (NightlyFeed.ps1 — offline) ─────────── +# The shared helper backs the "nightly dogfood feed is stale" banner at the top of every +# tracker. Format-NightlyFeedBanner is PURE (caller passes -Now), so it is fully tested +# offline with fixtures; Get-NightlyFeedFreshness is tested with an injected -Fetcher so no +# network is touched. Both are dot-sourced directly here (independent of engine load order). +Write-Host "`n[Unit] Nightly-feed banner (Format-NightlyFeedBanner — pure renderer)" -ForegroundColor Cyan +$nfHelperPath = Join-Path $PSScriptRoot '..' 'scripts' 'NightlyFeed.ps1' +. $nfHelperPath + +$nfLane = '[`dotnet10`](https://dev.azure.com/x) · `10.0.90` (main)' +function New-NfFresh { param($Ver, [datetime]$Pub) @{ laneLabel = $nfLane; version = $Ver; published = $Pub; matched = $true } } +$nfNow = [datetime]::new(2026, 6, 22, 12, 0, 0, [System.DateTimeKind]::Utc) + +# $null freshness → empty string (caller opted out of rendering). +Assert-Eq -Label "banner: null freshness → empty string" -Expected '' -Actual (Format-NightlyFeedBanner -Freshness $null -Now $nfNow) + +# unknown (feed query failed) → muted note, NOT a blockquote alarm. +$bUnknown = Format-NightlyFeedBanner -Freshness @{ laneLabel = $nfLane; unknown = $true } -Now $nfNow +Assert-Eq -Label "banner: unknown → muted 'could not be determined' note" -Expected $true -Actual ($bUnknown -match 'could not be determined') +Assert-Eq -Label "banner: unknown → not a ❌/⚠️ alarm" -Expected $false -Actual ($bUnknown -match '❌|⚠️') + +# matched=$false (queried, no build in band) → muted note. +$bNoMatch = Format-NightlyFeedBanner -Freshness @{ laneLabel = $nfLane; matched = $false } -Now $nfNow +Assert-Eq -Label "banner: no-match → muted 'no recent matching build' note" -Expected $true -Actual ($bNoMatch -match 'no recent matching build') + +# ✅ fresh tier (age < AgingDays=3): today / yesterday / N-days-ago wording. +$bToday = Format-NightlyFeedBanner -Freshness (New-NfFresh '10.0.90-ci.main.2' ([datetime]::new(2026,6,22,0,0,0,[System.DateTimeKind]::Utc))) -Now $nfNow +Assert-Eq -Label "banner: fresh today → ✅ + 'today'" -Expected $true -Actual ($bToday -match '✅' -and $bToday -match 'today') +Assert-Eq -Label "banner: fresh → renders the build version" -Expected $true -Actual ($bToday -match '10\.0\.90-ci\.main\.2') +Assert-Eq -Label "banner: fresh → renders the lane label" -Expected $true -Actual ($bToday -match 'dotnet10') +$bYday = Format-NightlyFeedBanner -Freshness (New-NfFresh 'v' ([datetime]::new(2026,6,21,0,0,0,[System.DateTimeKind]::Utc))) -Now $nfNow +Assert-Eq -Label "banner: 1 day → ✅ + 'yesterday'" -Expected $true -Actual ($bYday -match '✅' -and $bYday -match 'yesterday') +$b2d = Format-NightlyFeedBanner -Freshness (New-NfFresh 'v' ([datetime]::new(2026,6,20,0,0,0,[System.DateTimeKind]::Utc))) -Now $nfNow +Assert-Eq -Label "banner: 2 days (below aging) → ✅ + '2 days ago'" -Expected $true -Actual ($b2d -match '✅' -and $b2d -match '2 days ago') + +# ⚠️ aging tier (AgingDays=3 .. StaleDays-1) — includes the publish date (determinism check). +$bAging = Format-NightlyFeedBanner -Freshness (New-NfFresh 'v' ([datetime]::new(2026,6,18,0,0,0,[System.DateTimeKind]::Utc))) -Now $nfNow +Assert-Eq -Label "banner: 4 days → ⚠️ aging" -Expected $true -Actual ($bAging -match '⚠️' -and $bAging -match '4 days old') +Assert-Eq -Label "banner: aging → deterministic publish date" -Expected $true -Actual ($bAging -match '2026-06-18') + +# ❌ stale tier (>= StaleDays=7). +$bStale = Format-NightlyFeedBanner -Freshness (New-NfFresh 'v' ([datetime]::new(2026,6,10,0,0,0,[System.DateTimeKind]::Utc))) -Now $nfNow +Assert-Eq -Label "banner: 12 days → ❌ STALE" -Expected $true -Actual ($bStale -match '❌' -and $bStale -match 'STALE — 12 days') + +# Future publish (clock skew) clamps to age 0 — must not throw or emit a negative age. +$bFuture = $null; $nfFutureThrew = $false +try { $bFuture = Format-NightlyFeedBanner -Freshness (New-NfFresh 'v' ([datetime]::new(2026,6,24,0,0,0,[System.DateTimeKind]::Utc))) -Now $nfNow } catch { $nfFutureThrew = $true } +Assert-Eq -Label "banner: future publish → no throw" -Expected $false -Actual $nfFutureThrew +Assert-Eq -Label "banner: future publish → clamped to 'today'" -Expected $true -Actual ($bFuture -match '✅' -and $bFuture -match 'today') + +# Caller-tunable thresholds: a 4-day-old build is ⚠️ by default but ✅ under a wider window. +$bWide = Format-NightlyFeedBanner -Freshness (New-NfFresh 'v' ([datetime]::new(2026,6,18,0,0,0,[System.DateTimeKind]::Utc))) -Now $nfNow -AgingDays 10 -StaleDays 20 +Assert-Eq -Label "banner: custom AgingDays=10 → 4d build is ✅ fresh" -Expected $true -Actual ($bWide -match '✅') + +Write-Host "`n[Unit] Nightly-feed freshness query (Get-NightlyFeedFreshness — mocked fetcher)" -ForegroundColor Cyan +# Self-contained fetcher: emulates the Azure Artifacts service index + a SemVer2 +# registration page with inline catalog leaves. Mixes bands + intentionally non-date-sorted +# versions so the date-not-version selection and the prefix filter are both exercised. +$nfMock = { + param($Url) + if ($Url -match '_packaging/.+/nuget/v3/index\.json$') { + return [pscustomobject]@{ resources = @( + [pscustomobject]@{ '@type' = 'SearchQueryService'; '@id' = 'https://example/search' }, + [pscustomobject]@{ '@type' = 'RegistrationsBaseUrl/3.6.0'; '@id' = 'https://reg.example/3.6.0/' } + ) } + } + if ($Url -match '/3\.6\.0/.+/index\.json$') { + $mk = { param($v, $p) [pscustomobject]@{ catalogEntry = [pscustomobject]@{ version = $v; published = $p } } } + return [pscustomobject]@{ items = @( + [pscustomobject]@{ items = @( + (& $mk '10.0.90-ci.main.1' '2026-06-20T03:00:00Z'), + (& $mk '10.0.90-ci.main.2' '2026-06-22T03:00:00Z'), + (& $mk '10.0.90-ci.main.10' '2026-06-01T03:00:00Z'), + (& $mk '10.0.80-ci.inflight.5' '2026-06-25T03:00:00Z') + ) } + ) } + } + throw "unexpected url $Url" +} + +$r90 = Get-NightlyFeedFreshness -Feed 'dotnet10' -VersionPrefixRegex '^10\.0\.90-' -Fetcher $nfMock +Assert-Eq -Label "feed: band 90 → matched" -Expected $true -Actual $r90.matched +Assert-Eq -Label "feed: band 90 → newest by DATE not version (.2)" -Expected '10.0.90-ci.main.2' -Actual $r90.version +Assert-Eq -Label "feed: band 90 → published date surfaced" -Expected '2026-06-22' -Actual ($r90.published.ToString('yyyy-MM-dd')) +Assert-Eq -Label "feed: prefix excludes the newer .80 inflight band" -Expected $false -Actual ($r90.version -match '10\.0\.80') + +$r80 = Get-NightlyFeedFreshness -Feed 'dotnet10' -VersionPrefixRegex '^10\.0\.80-' -Fetcher $nfMock +Assert-Eq -Label "feed: band 80 → isolates the inflight build" -Expected '10.0.80-ci.inflight.5' -Actual $r80.version + +$rNo = Get-NightlyFeedFreshness -Feed 'dotnet10' -VersionPrefixRegex '^10\.0\.70-' -Fetcher $nfMock +Assert-Eq -Label "feed: band with no build → matched is false" -Expected $false -Actual $rNo.matched + +# Fail-open: any fetcher error → $null (transient outage never breaks tracker generation). +$rThrow = Get-NightlyFeedFreshness -Feed 'dotnet10' -VersionPrefixRegex '^10\.0\.90-' -Fetcher { param($Url) throw 'boom' } +Assert-Eq -Label "feed: fetcher throws → null (fail-open)" -Expected $true -Actual ($null -eq $rThrow) + +# Paged registration: a page that carries only an @id (no inline items) is followed. +$nfPaged = { + param($Url) + if ($Url -match '_packaging/.+/nuget/v3/index\.json$') { + return [pscustomobject]@{ resources = @([pscustomobject]@{ '@type' = 'RegistrationsBaseUrl/3.6.0'; '@id' = 'https://reg.example/3.6.0/' }) } + } + if ($Url -match '/3\.6\.0/.+/index\.json$') { + return [pscustomobject]@{ items = @([pscustomobject]@{ '@id' = 'https://reg.example/page1.json' }) } + } + if ($Url -match '/page1\.json$') { + return [pscustomobject]@{ items = @([pscustomobject]@{ catalogEntry = [pscustomobject]@{ version = '11.0.0-preview.6.123'; published = '2026-06-21T00:00:00Z' } }) } + } + throw "unexpected url $Url" +} +$rPaged = Get-NightlyFeedFreshness -Feed 'dotnet11' -VersionPrefixRegex '^11\.0\.0-preview\.6\.' -Fetcher $nfPaged +Assert-Eq -Label "feed: paged @id leaf is followed" -Expected '11.0.0-preview.6.123' -Actual $rPaged.version + Write-Host "`n────────────────────────────────────────" -ForegroundColor Cyan Write-Host "Passed: $script:passed Failed: $script:failed" -ForegroundColor $(if ($script:failed -eq 0) { 'Green' } else { 'Red' }) exit $(if ($script:failed -eq 0) { 0 } else { 1 }) From cabd3dc46add2a8fa25ad173efb4b8127201f2e7 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:45:46 -0500 Subject: [PATCH 2/9] Track ci.inflight dogfood stream (not ci.main) for nightly-feed banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly-feed staleness banner watched the lane's version band, whose freshest build is almost always the daily ci.main CI build (e.g. 10.0.90-ci.main, fresh every day). That masks the failure the banner exists to catch: a stall in the *inflight* stream (ci.inflight — builds of the inflight/current branch, the 'shipping next' dogfood bits). eng/Versions.props on main switches the label to ci.inflight when BUILD_SOURCEBRANCH is refs/heads/inflight/current; ordinary main CI is ci.main. Add Resolve-NightlyDogfoodFreshness: prefer the newest ci.inflight build on the feed (resolved feed-wide so it auto-follows band advances 10.0.80 -> 10.0.90), falling back to the lane band only when the feed has NO inflight builds (e.g. a preview feed not yet in the inflight phase). A *transient* inflight-query failure degrades to 'unknown' rather than falling through to the always-fresh ci.main band, so a stalled feed is never painted green. Both engines (SR + preview) now route through the resolver and label the banner by build type (ci.inflight vs band). Live result today: dotnet10 shows STALE 15 days (10.0.80-ci.inflight stopped 2026-06-07, the failing inflight build), while dotnet11-preview6 stays fresh via its preview build. Tests: offline 600/0 (+8 resolver cases incl. the transient-error safety path); full suite 610/2 (2 pre-existing env flakes in the unmodified Find-ReleaseReadinessTrackers.ps1). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Get-PreviewReadiness.ps1 | 23 +++--- .../scripts/Get-ReleaseReadiness.ps1 | 29 ++++---- .../release-readiness/scripts/NightlyFeed.ps1 | 71 ++++++++++++++++++- .../tests/Test-ReleaseReadiness.ps1 | 61 ++++++++++++++++ 4 files changed, 158 insertions(+), 26 deletions(-) diff --git a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 index 49425d17b731..eec13b2bfb2f 100644 --- a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 @@ -1362,14 +1362,14 @@ $report = [PSCustomObject]@{ NightlyFeed = $null } -# Nightly dogfood feed freshness (preview lane). Maps this preview to the dotnet -# Azure Artifacts feed + preview.N version band (from PreReleaseVersionIteration at the -# survey ref, falling back to the branch's preview number) and records how fresh the newest -# matching build is. Fail-open: any gap (helper unloaded, version unreadable, network error) -# degrades to "no banner". +# Nightly dogfood feed freshness (preview lane). Tracks the inflight/current dogfood stream +# (ci.inflight builds) on the dotnet feed; falls back to this preview's preview.N +# version band when the feed has no inflight builds yet (the common case while a major is +# still in preview — its newest bits ARE the preview.N builds). Fail-open: any gap (helper +# unloaded, version unreadable, network error) degrades to "no banner". $nightlyFeedBanner = $null if ($Script:NightlyFeedHelperLoaded -and - (Get-Command Get-NightlyFeedFreshness -ErrorAction SilentlyContinue) -and + (Get-Command Resolve-NightlyDogfoodFreshness -ErrorAction SilentlyContinue) -and (Get-Command Format-NightlyFeedBanner -ErrorAction SilentlyContinue)) { try { $nfFeed = "dotnet$majorVersion" @@ -1377,14 +1377,17 @@ if ($Script:NightlyFeedHelperLoaded -and $nfIteration = Get-PreReleaseVersionIteration -BranchName $SurveyRef if ([string]::IsNullOrWhiteSpace($nfIteration)) { $nfIteration = "$previewNumber" } $nfBand = "$majorVersion.0.0-preview.$nfIteration" - $nfPrefix = '^' + [regex]::Escape("$nfBand.") - $nfLaneLabel = "[``$nfFeed``]($nfFeedUrl) · ``$nfBand`` (preview.$nfIteration)" + $nfBandPrefix = '^' + [regex]::Escape("$nfBand.") - $nfFresh = Get-NightlyFeedFreshness -Feed $nfFeed -VersionPrefixRegex $nfPrefix + $nfFresh = Resolve-NightlyDogfoodFreshness -Feed $nfFeed -BandPrefixRegex $nfBandPrefix if ($null -eq $nfFresh) { $nfFresh = @{ unknown = $true } } + + $nfBuildType = [string](Get-NightlyFeedProp $nfFresh 'buildType') + $nfTypeNote = if ($nfBuildType -eq 'inflight') { 'ci.inflight' } else { "``$nfBand`` (preview.$nfIteration)" } + $nfLaneLabel = "[``$nfFeed``]($nfFeedUrl) · $nfTypeNote" $nfFresh['laneLabel'] = $nfLaneLabel $nfFresh['feedUrl'] = $nfFeedUrl - $nfFresh['versionPrefix'] = $nfPrefix + $nfFresh['versionPrefix'] = $nfBandPrefix $report.NightlyFeed = $nfFresh $nightlyFeedBanner = Format-NightlyFeedBanner -Freshness $nfFresh -Now ([DateTime]::UtcNow) diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index b89853439486..afbb711842f6 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -3620,18 +3620,21 @@ function Add-SrNightlyFeedFreshness { .DESCRIPTION Lane → feed/band mapping (verified against the live feeds): - feed = dotnet (e.g. dotnet10, dotnet11) + - signal = the inflight/current dogfood stream (ci.inflight builds) on that feed — + the "shipping next" bits dogfooders validate against. Resolved feed-wide + (not band-pinned) so it auto-follows when inflight/current advances bands + (e.g. 10.0.80 → 10.0.90). Ordinary main CI (ci.main) is deliberately NOT + tracked: it publishes daily and would paint an inflight stall green. - band = .0. (PatchVersion from eng/Versions.props at srRef) - in-flight SR → srRef is the SR branch → inflight band (e.g. 10.0.80) - candidate SR → srRef is origin/main → main band (e.g. 10.0.90) - The band is matched as a literal version *prefix* (`^.0.-`), which is - resilient to family-keyword churn in the build metadata (ci.net10 → ci.main, etc.). + used only as a FALLBACK when the feed has no inflight builds at all + (e.g. a preview feed not yet in the inflight phase). Fail-open throughout: any gap (helper not loaded, version unreadable, network error) - degrades to "no banner" rather than disturbing the verdict. + degrades to "no banner"/"unknown" rather than disturbing the verdict. #> param([hashtable]$Data) if (-not $Script:NightlyFeedHelperLoaded) { return } - if (-not (Get-Command Get-NightlyFeedFreshness -ErrorAction SilentlyContinue)) { return } + if (-not (Get-Command Resolve-NightlyDogfoodFreshness -ErrorAction SilentlyContinue)) { return } if (-not (Get-Command Format-NightlyFeedBanner -ErrorAction SilentlyContinue)) { return } try { @@ -3645,17 +3648,17 @@ function Add-SrNightlyFeedFreshness { $band = "$major.0.$patch" $feed = "dotnet$major" $feedUrl = "https://dev.azure.com/dnceng/public/_artifacts/feed/$feed" - $prefix = '^' + [regex]::Escape($band) + '-' + $bandPrefix = '^' + [regex]::Escape($band) + '-' - $mode = if ($ctx.ContainsKey('mode')) { $ctx['mode'] } else { 'in-flight' } - $familyWord = if ($mode -eq 'candidate') { 'main' } else { 'inflight' } - $laneLabel = "[``$feed``]($feedUrl) · ``$band`` ($familyWord)" - - $fresh = Get-NightlyFeedFreshness -Feed $feed -VersionPrefixRegex $prefix + $fresh = Resolve-NightlyDogfoodFreshness -Feed $feed -BandPrefixRegex $bandPrefix if ($null -eq $fresh) { $fresh = @{ unknown = $true } } + + $buildType = [string](Get-NightlyFeedProp $fresh 'buildType') + $typeNote = if ($buildType -eq 'inflight') { 'ci.inflight' } else { "``$band``" } + $laneLabel = "[``$feed``]($feedUrl) · $typeNote" $fresh['laneLabel'] = $laneLabel $fresh['feedUrl'] = $feedUrl - $fresh['versionPrefix'] = $prefix + $fresh['versionPrefix'] = $bandPrefix $Data['nightlyFeed'] = $fresh $banner = Format-NightlyFeedBanner -Freshness $fresh -Now ([DateTime]::UtcNow) diff --git a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 index 790b21036252..c66b7eb90d1e 100644 --- a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 +++ b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 @@ -27,9 +27,12 @@ NOT by date, and mixes several build families (e.g. dotnet10 carries ci.main, ci.inflight and ci.net10). Freshness MUST therefore be derived from the catalog `published` timestamps, scoped to the family the caller cares about via a version - prefix (band-based, e.g. `^10\.0\.90-` for main vs `^10\.0\.80-` for the SR8 inflight - band). This is resilient to the recurring family-keyword churn (ci.net9 → ci.net10 → - ci.main; c1.net11 → ci.net11 → preview.6). + prefix. The signal that matters for release readiness is the *inflight* stream + (`ci.inflight` — builds of the `inflight/current` branch, the "shipping next" dogfood + bits); ordinary main CI (`ci.main`) publishes daily and would mask an inflight stall. + Resolve-NightlyDogfoodFreshness encodes that preference (inflight first, lane band only + as a fallback for feeds with no inflight builds), and is resilient to the recurring + family-keyword churn (ci.net9 → ci.net10 → ci.main; c1.net11 → ci.net11 → preview.6). #> Set-StrictMode -Version Latest @@ -162,6 +165,68 @@ function Get-NightlyFeedFreshness { } } +function Resolve-NightlyDogfoodFreshness { + <# + .SYNOPSIS + Resolve the dogfood-feed freshness signal for a release lane, preferring the + inflight/current stream and only falling back to the lane's version band when the + feed genuinely has no inflight builds. + .DESCRIPTION + The dogfood feed (dotnet10, dotnet11, …) carries several build families. The one + that matters for release readiness is the *inflight* stream — builds of the + `inflight/current` branch, tagged `ci.inflight`, which carry the "shipping next" + bits dogfooders validate against. (eng/Versions.props on main switches the label to + `ci.inflight` when BUILD_SOURCEBRANCH is refs/heads/inflight/current.) Ordinary main + CI (`ci.main`) publishes daily and is almost always fresh, so matching it would mask + an inflight outage — which is exactly the failure this banner exists to surface. + + Resolution order (per feed): + 1. Newest `ci.inflight` build → buildType = 'inflight' (the real dogfood signal) + 2. If the feed has NO inflight builds at all (definitive matched=$false — e.g. a + preview feed not yet in the inflight phase), fall back to the lane's band + prefix → buildType = 'band'. + + Safety: a *transient* failure of the inflight query (Get-NightlyFeedFreshness + returns $null) does NOT fall through to the band match — that could surface the + always-fresh `ci.main` band and paint a stalled inflight feed green. Instead it + degrades to @{ unknown = $true } (muted "could not be determined" note). The + fall-back to band only happens on a *definitive* "no inflight builds" answer. + .OUTPUTS + A freshness hashtable as returned by Get-NightlyFeedFreshness, augmented with a + 'buildType' key ('inflight' | 'band'); or @{ unknown = $true } when freshness + could not be determined. + #> + param( + [Parameter(Mandatory)][string]$Feed, + [Parameter(Mandatory)][string]$BandPrefixRegex, + [string]$InflightPrefixRegex = 'ci\.inflight\.', + [string]$Package = 'Microsoft.Maui.Controls', + [int]$TimeoutSec = 20, + [scriptblock]$Fetcher + ) + + $common = @{ Feed = $Feed; Package = $Package; TimeoutSec = $TimeoutSec } + if ($Fetcher) { $common['Fetcher'] = $Fetcher } + + $inflight = Get-NightlyFeedFreshness @common -VersionPrefixRegex $InflightPrefixRegex + if ($null -eq $inflight) { + # Transient/hard failure querying the inflight stream. Do NOT fall back to the band + # (would risk reporting the always-fresh ci.main build and hiding an inflight stall). + return @{ unknown = $true } + } + if (Get-NightlyFeedProp $inflight 'matched') { + $inflight['buildType'] = 'inflight' + return $inflight + } + + # Definitive "no inflight builds on this feed" → fall back to the lane's version band + # (e.g. a preview feed whose newest bits are its preview.N builds). + $band = Get-NightlyFeedFreshness @common -VersionPrefixRegex $BandPrefixRegex + if ($null -eq $band) { return @{ unknown = $true } } + $band['buildType'] = 'band' + return $band +} + function Format-NightlyFeedBanner { <# .SYNOPSIS diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index 63dc44913780..d3f505a26e5b 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -3516,6 +3516,67 @@ $nfPaged = { $rPaged = Get-NightlyFeedFreshness -Feed 'dotnet11' -VersionPrefixRegex '^11\.0\.0-preview\.6\.' -Fetcher $nfPaged Assert-Eq -Label "feed: paged @id leaf is followed" -Expected '11.0.0-preview.6.123' -Actual $rPaged.version +Write-Host "`n[Unit] Nightly-feed dogfood resolution (Resolve-NightlyDogfoodFreshness — inflight-primary)" -ForegroundColor Cyan + +# (a) Feed WITH an inflight stream: resolver must pick the ci.inflight build even when a +# *fresher* ci.main build exists in the lane band — ci.main is deliberately NOT the dogfood +# signal (it publishes daily and would mask an inflight stall). +$nfInflightMock = { + param($Url) + if ($Url -match '_packaging/.+/nuget/v3/index\.json$') { + return [pscustomobject]@{ resources = @([pscustomobject]@{ '@type'='RegistrationsBaseUrl/3.6.0'; '@id'='https://reg.example/3.6.0/' }) } + } + if ($Url -match '/3\.6\.0/.+/index\.json$') { + $mk = { param($v,$p) [pscustomobject]@{ catalogEntry = [pscustomobject]@{ version=$v; published=$p } } } + return [pscustomobject]@{ items = @([pscustomobject]@{ items = @( + (& $mk '10.0.80-ci.inflight.5' '2026-06-07T03:00:00Z'), # dogfood signal (older) + (& $mk '10.0.90-ci.main.99' '2026-06-22T03:00:00Z') # fresher, but NOT dogfood + ) }) } + } + throw "unexpected url $Url" +} +$rInf = Resolve-NightlyDogfoodFreshness -Feed 'dotnet10' -BandPrefixRegex '^10\.0\.90-' -Fetcher $nfInflightMock +Assert-Eq -Label "resolve: inflight present → buildType inflight" -Expected 'inflight' -Actual $rInf.buildType +Assert-Eq -Label "resolve: inflight preferred over fresher ci.main" -Expected '10.0.80-ci.inflight.5' -Actual $rInf.version + +# (b) Feed with NO inflight builds (preview feed): fall back to the lane preview band. +$nfPreviewMock = { + param($Url) + if ($Url -match '_packaging/.+/nuget/v3/index\.json$') { + return [pscustomobject]@{ resources = @([pscustomobject]@{ '@type'='RegistrationsBaseUrl/3.6.0'; '@id'='https://reg.example/3.6.0/' }) } + } + if ($Url -match '/3\.6\.0/.+/index\.json$') { + return [pscustomobject]@{ items = @([pscustomobject]@{ items = @( + [pscustomobject]@{ catalogEntry = [pscustomobject]@{ version='11.0.0-preview.6.123'; published='2026-06-22T00:00:00Z' } } + ) }) } + } + throw "unexpected url $Url" +} +$rPrev = Resolve-NightlyDogfoodFreshness -Feed 'dotnet11' -BandPrefixRegex '^11\.0\.0-preview\.6\.' -Fetcher $nfPreviewMock +Assert-Eq -Label "resolve: no inflight → falls back to band" -Expected 'band' -Actual $rPrev.buildType +Assert-Eq -Label "resolve: band fallback returns preview build" -Expected '11.0.0-preview.6.123' -Actual $rPrev.version + +# (c) No inflight AND band also absent → matched=$false (muted "no matching build" note). +$rNone = Resolve-NightlyDogfoodFreshness -Feed 'dotnet11' -BandPrefixRegex '^11\.0\.0-preview\.9\.' -Fetcher $nfPreviewMock +Assert-Eq -Label "resolve: no inflight + no band → matched false" -Expected $false -Actual $rNone.matched +Assert-Eq -Label "resolve: no inflight + no band → buildType band" -Expected 'band' -Actual $rNone.buildType + +# (d) SAFETY: a *transient* inflight-query failure must NOT fall through to the fresh band +# (that would paint a stalled inflight feed green). It must degrade to unknown instead. +$nfOrigDef = (Get-Item function:Get-NightlyFeedFreshness).ScriptBlock +function Get-NightlyFeedFreshness { + param([Parameter(Mandatory)][string]$Feed,[string]$Package='Microsoft.Maui.Controls',[string]$VersionPrefixRegex,[int]$TimeoutSec=20,[scriptblock]$Fetcher) + if ($VersionPrefixRegex -match 'inflight') { return $null } # simulate transient inflight outage + return @{ feed=$Feed; package=$Package; version='10.0.90-ci.main.fresh'; published=[datetime]::new(2026,6,22,0,0,0,[System.DateTimeKind]::Utc); matched=$true } +} +try { + $rSafety = Resolve-NightlyDogfoodFreshness -Feed 'dotnet10' -BandPrefixRegex '^10\.0\.90-' + Assert-Eq -Label "resolve: transient inflight error → unknown (not false-green band)" -Expected $true -Actual ([bool](Get-NightlyFeedProp $rSafety 'unknown')) + Assert-Eq -Label "resolve: transient inflight error → does NOT surface ci.main band" -Expected $true -Actual ([string]::IsNullOrEmpty([string](Get-NightlyFeedProp $rSafety 'version'))) +} finally { + Set-Item function:Get-NightlyFeedFreshness $nfOrigDef # restore real helper +} + Write-Host "`n────────────────────────────────────────" -ForegroundColor Cyan Write-Host "Passed: $script:passed Failed: $script:failed" -ForegroundColor $(if ($script:failed -eq 0) { 'Green' } else { 'Red' }) exit $(if ($script:failed -eq 0) { 0 } else { 1 }) From a5fad73540d6eca7d45d46d69c8d42b1b4d5c373 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:21:18 -0500 Subject: [PATCH 3/9] Fold nightly-feed banner state into SR idempotency hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nightly dogfood banner was excluded from Get-ReportSemanticHash, so on a quiet SR in-flight tracker (no commit/CI/PR/regression change) the idempotent no-op skipped the issue edit and the banner never appeared or refreshed — the exact case the banner exists for (e.g. sr8 #35876: feed 15d stale, no banner). Fix: add a pure Get-NightlyFeedTier helper (single source of truth for the fresh/aging/stale/unknown/no-match bucket, sharing the renderer's thresholds) and fold a non-drifting 'tier|version' signature into the semantic hash. Tier crossings (ok->aging->stale) and new builds flip the hash and refresh the tracker; a daily day-count tick within the same tier does not (no watcher spam). Fail-open: if NightlyFeed.ps1 isn't loaded the field is '' and the hash is unchanged from prior behaviour. Tests: +11 Get-NightlyFeedTier bucket cases, +6 hash-integration cases (ok vs stale differs, day-count drift invariant, new build differs, present vs absent differs, unknown differs, deterministic). Offline suite 617/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Get-ReleaseReadiness.ps1 | 14 +++++ .../release-readiness/scripts/NightlyFeed.ps1 | 45 +++++++++++++++ .../tests/Test-ReleaseReadiness.ps1 | 55 +++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index afbb711842f6..ce78a0e03b68 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -3011,6 +3011,20 @@ function Get-ReportSemanticHash { "$($_.Area):$($_.Status)" }) -join '|' } else { '' } + # Nightly dogfood feed banner state. Folded in so a feed going stale (or a + # fresh build landing) refreshes the tracker even on an otherwise-quiet branch + # — the banner is the whole point of the feature and must not be frozen out by + # the idempotent no-op. We hash the non-drifting tier + resolved version (NOT the + # "N days" count) so threshold crossings and new builds flip the hash but a daily + # day-count tick within the same tier does not (no watcher spam). Fail-open: if the + # NightlyFeed helper isn't loaded, contributes '' (hash behaves as before). + nightlyFeed = if ($Data.ContainsKey('nightlyFeed') -and $Data['nightlyFeed'] -and + (Get-Command Get-NightlyFeedTier -ErrorAction SilentlyContinue)) { + $nf = $Data['nightlyFeed'] + $tier = Get-NightlyFeedTier -Freshness $nf -Now ([datetime]::UtcNow) + $ver = [string](Get-NightlyFeedProp $nf 'version') + if ($ver) { "$tier|$ver" } else { $tier } + } else { '' } } $json = $semantic | ConvertTo-Json -Depth 5 -Compress diff --git a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 index c66b7eb90d1e..3c63b8bc697f 100644 --- a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 +++ b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 @@ -227,6 +227,51 @@ function Resolve-NightlyDogfoodFreshness { return $band } +function Get-NightlyFeedTier { + <# + .SYNOPSIS + Classify a nightly-feed freshness record into a stable, non-drifting tier token. + .DESCRIPTION + PURE. Returns one of: + 'none' — no record / no usable build (caller renders nothing) + 'unknown' — freshness query failed this run + 'no-match' — feed queried but no matching build (naming changed) + 'ok' — newest build < AgingDays old + 'aging' — AgingDays <= age < StaleDays + 'stale' — age >= StaleDays + + This is the SAME bucketing the banner renderer (Format-NightlyFeedBanner) uses, factored + out so the idempotency hash (Get-ReportSemanticHash) can fold the banner's *state* into the + tracker's semantic signature WITHOUT pulling in the drifting "N days" count. Tier flips + (ok->aging->stale) and new builds (version change) flip the hash and refresh the tracker; + a day-count tick within the same tier does not (keeps the no-op idempotency intact). + + Thresholds default to the same $Script:NightlyFeed*Days constants the renderer uses — keep + the two in lock-step if either changes. + #> + param( + $Freshness, + [datetime]$Now, + [int]$AgingDays = $Script:NightlyFeedAgingDays, + [int]$StaleDays = $Script:NightlyFeedStaleDays + ) + + if ($null -eq $Freshness) { return 'none' } + if (Get-NightlyFeedProp $Freshness 'unknown') { return 'unknown' } + $matched = Get-NightlyFeedProp $Freshness 'matched' + if ($null -ne $matched -and -not $matched) { return 'no-match' } + + $version = [string](Get-NightlyFeedProp $Freshness 'version') + $published = Get-NightlyFeedProp $Freshness 'published' + if ([string]::IsNullOrWhiteSpace($version) -or $null -eq $published) { return 'none' } + + $age = [int][Math]::Floor((($Now.ToUniversalTime()) - ([datetime]$published).ToUniversalTime()).TotalDays) + if ($age -lt 0) { $age = 0 } + if ($age -ge $StaleDays) { return 'stale' } + if ($age -ge $AgingDays) { return 'aging' } + return 'ok' +} + function Format-NightlyFeedBanner { <# .SYNOPSIS diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index d3f505a26e5b..7acbdd83a616 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -3577,6 +3577,61 @@ try { Set-Item function:Get-NightlyFeedFreshness $nfOrigDef # restore real helper } +# ───── Get-NightlyFeedTier (banner-state bucketing for the idempotency hash) ───── +Write-Host "`n[Unit] Get-NightlyFeedTier (stable banner-state bucket)" -ForegroundColor Cyan +$tierNow = [datetime]::new(2026, 6, 22, 0, 0, 0, [System.DateTimeKind]::Utc) +function New-NfPub([int]$daysAgo) { return $tierNow.AddDays(-$daysAgo) } +Assert-Eq -Label "tier: null record → none" -Expected 'none' -Actual (Get-NightlyFeedTier -Freshness $null -Now $tierNow) +Assert-Eq -Label "tier: unknown record → unknown" -Expected 'unknown' -Actual (Get-NightlyFeedTier -Freshness @{ unknown = $true } -Now $tierNow) +Assert-Eq -Label "tier: matched=false → no-match" -Expected 'no-match' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $false } -Now $tierNow) +Assert-Eq -Label "tier: empty version → none" -Expected 'none' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = ''; published = (New-NfPub 0) } -Now $tierNow) +Assert-Eq -Label "tier: null published → none" -Expected 'none' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = $null } -Now $tierNow) +Assert-Eq -Label "tier: age 0 → ok" -Expected 'ok' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = (New-NfPub 0) } -Now $tierNow) +Assert-Eq -Label "tier: age 2 (< aging 3) → ok" -Expected 'ok' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = (New-NfPub 2) } -Now $tierNow) +Assert-Eq -Label "tier: age 3 (= aging) → aging" -Expected 'aging' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = (New-NfPub 3) } -Now $tierNow) +Assert-Eq -Label "tier: age 6 (< stale 7) → aging" -Expected 'aging' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = (New-NfPub 6) } -Now $tierNow) +Assert-Eq -Label "tier: age 7 (= stale) → stale" -Expected 'stale' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = (New-NfPub 7) } -Now $tierNow) +Assert-Eq -Label "tier: age 15 → stale" -Expected 'stale' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = (New-NfPub 15) } -Now $tierNow) + +# ───── Get-ReportSemanticHash folds in nightly-feed banner state ───── +# Regression guard for the idempotency bug: a quiet SR tracker whose ONLY change is the +# nightly feed going stale must still refresh (the banner is the point of the feature), +# while a daily day-count tick within the SAME tier must NOT churn the issue. +Write-Host "`n[Unit] Get-ReportSemanticHash × nightly-feed banner state" -ForegroundColor Cyan +$nfV = @{ symbol = '🟡' } +$nfBase = { + @{ + metadata = @{ srHeadSha = 'cafe12345678' } + ci = @{ overall = 'green' } + srContents = @{ sourcePrs = @(35001, 35002) } + regressions = @() + openSrPrs = @() + shipChecks = @() + } +} +# Published dates are real-now-relative because the hash computes the tier with [datetime]::UtcNow. +$nfNow = [datetime]::UtcNow +$dNoFeed = & $nfBase +$dOk = & $nfBase; $dOk['nightlyFeed'] = @{ matched = $true; version = '10.0.90-ci.inflight.1'; published = $nfNow } +$dStale = & $nfBase; $dStale['nightlyFeed'] = @{ matched = $true; version = '10.0.90-ci.inflight.1'; published = $nfNow.AddDays(-20) } +$dStale2 = & $nfBase; $dStale2['nightlyFeed'] = @{ matched = $true; version = '10.0.90-ci.inflight.1'; published = $nfNow.AddDays(-9) } # still stale, different day count, SAME version +$dNewBuild = & $nfBase; $dNewBuild['nightlyFeed'] = @{ matched = $true; version = '10.0.90-ci.inflight.2'; published = $nfNow } # fresh build → ok tier, NEW version +$dUnknown = & $nfBase; $dUnknown['nightlyFeed'] = @{ unknown = $true } + +$hNoFeed = Get-ReportSemanticHash -Data $dNoFeed -Verdict $nfV +$hOk = Get-ReportSemanticHash -Data $dOk -Verdict $nfV +$hStale = Get-ReportSemanticHash -Data $dStale -Verdict $nfV +$hStale2 = Get-ReportSemanticHash -Data $dStale2 -Verdict $nfV +$hNew = Get-ReportSemanticHash -Data $dNewBuild -Verdict $nfV +$hUnk = Get-ReportSemanticHash -Data $dUnknown -Verdict $nfV + +Assert-Eq -Label "hash: feed ok vs stale → DIFFERENT (banner refreshes on stall)" -Expected $false -Actual ($hOk -eq $hStale) +Assert-Eq -Label "hash: stale day-count drift, same tier+version → SAME (no daily spam)" -Expected $true -Actual ($hStale -eq $hStale2) +Assert-Eq -Label "hash: new build (version change), same ok tier → DIFFERENT" -Expected $false -Actual ($hOk -eq $hNew) +Assert-Eq -Label "hash: feed present vs absent → DIFFERENT" -Expected $false -Actual ($hOk -eq $hNoFeed) +Assert-Eq -Label "hash: unknown tier vs ok → DIFFERENT" -Expected $false -Actual ($hOk -eq $hUnk) +Assert-Eq -Label "hash: nightly-feed fold is deterministic" -Expected $hStale -Actual (Get-ReportSemanticHash -Data $dStale -Verdict $nfV) + Write-Host "`n────────────────────────────────────────" -ForegroundColor Cyan Write-Host "Passed: $script:passed Failed: $script:failed" -ForegroundColor $(if ($script:failed -eq 0) { 'Green' } else { 'Red' }) exit $(if ($script:failed -eq 0) { 0 } else { 1 }) From f576bb01a1bf48e6703db1e199d4833146867ee9 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:48:45 -0500 Subject: [PATCH 4/9] Harden nightly-feed banner: split-clock hash, ci.main false-green guard, fail-open telemetry Adversarial-review fixes for the nightly dogfood feed staleness banner: - Split-clock (SR idempotency hash): Get-ReportSemanticHash sampled [datetime]::UtcNow independently from the banner render, so a run that straddled a tier boundary could embed a hash tier that disagreed with the displayed banner tier and freeze a stale banner via the no-op gate. Add-SrNightlyFeedFreshness now captures one instant in $Data['nightlyFeedNow'] and both the banner render and the hash tier reuse it (UtcNow fallback when unset). - ci.main false-green guard (Resolve-NightlyDogfoodFreshness): an SR lane's band prefix (e.g. ^10\.0\.90-) also matches the always-fresh ci.main stream, so the no-inflight band fallback could surface a fresh ci.main build and paint a stalled inflight feed green. The fallback now reports matched=false for a ci.main-only band. Preview bands (preview.N) never match ci.main, so this is a no-op for preview. - Fail-open telemetry (Get-NightlyFeedFreshness): the swallowing catch now emits a Write-Warning with the feed and exception so a real outage (401/503/DNS) isn't silently invisible in the unattended job's log. - Honest lane label: an unknown/transient-failure result is labelled ci.inflight (the stream being measured) instead of implying the band carries the signal. +7 regression asserts (2 fail-pre-fix for the ci.main guard, 1 for the split-clock hash, plus over-filter and tier sanity guards). Offline suite 624/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Get-ReleaseReadiness.ps1 | 23 +++++++- .../release-readiness/scripts/NightlyFeed.ps1 | 14 +++++ .../tests/Test-ReleaseReadiness.ps1 | 59 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index ce78a0e03b68..3fb50e7494ca 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -3021,7 +3021,14 @@ function Get-ReportSemanticHash { nightlyFeed = if ($Data.ContainsKey('nightlyFeed') -and $Data['nightlyFeed'] -and (Get-Command Get-NightlyFeedTier -ErrorAction SilentlyContinue)) { $nf = $Data['nightlyFeed'] - $tier = Get-NightlyFeedTier -Freshness $nf -Now ([datetime]::UtcNow) + # Reuse the SAME instant the banner was rendered with (stored by + # Add-SrNightlyFeedFreshness) so the hashed tier can never disagree with + # the displayed banner tier and freeze a stale banner via the no-op gate. + # Fall back to UtcNow when unset (e.g. unit tests that inject nightlyFeed directly). + $nfNow = if ($Data.ContainsKey('nightlyFeedNow') -and $Data['nightlyFeedNow']) { + [datetime]$Data['nightlyFeedNow'] + } else { [datetime]::UtcNow } + $tier = Get-NightlyFeedTier -Freshness $nf -Now $nfNow $ver = [string](Get-NightlyFeedProp $nf 'version') if ($ver) { "$tier|$ver" } else { $tier } } else { '' } @@ -3668,14 +3675,24 @@ function Add-SrNightlyFeedFreshness { if ($null -eq $fresh) { $fresh = @{ unknown = $true } } $buildType = [string](Get-NightlyFeedProp $fresh 'buildType') - $typeNote = if ($buildType -eq 'inflight') { 'ci.inflight' } else { "``$band``" } + # Label honestly: 'inflight' → ci.inflight; a definitive band fallback → the band; + # anything else (unknown / transient inflight failure) → ci.inflight, the stream we + # were measuring — never imply the band carries the signal when freshness is unknown. + $typeNote = if ($buildType -eq 'inflight') { 'ci.inflight' } + elseif ($buildType -eq 'band') { "``$band``" } + else { 'ci.inflight' } $laneLabel = "[``$feed``]($feedUrl) · $typeNote" $fresh['laneLabel'] = $laneLabel $fresh['feedUrl'] = $feedUrl $fresh['versionPrefix'] = $bandPrefix + # Capture ONE timestamp and reuse it for both the banner render and the semantic-hash + # tier (Get-ReportSemanticHash reads $Data['nightlyFeedNow']) so the two can never + # sample different sides of a tier boundary within a single run. + $nfNow = [DateTime]::UtcNow $Data['nightlyFeed'] = $fresh - $banner = Format-NightlyFeedBanner -Freshness $fresh -Now ([DateTime]::UtcNow) + $Data['nightlyFeedNow'] = $nfNow + $banner = Format-NightlyFeedBanner -Freshness $fresh -Now $nfNow if ($banner) { $Data['nightlyFeedBanner'] = $banner } } catch { Write-Warning "Nightly-feed freshness check failed (non-fatal): $($_.Exception.Message)" diff --git a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 index 3c63b8bc697f..7c78af27ad6a 100644 --- a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 +++ b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 @@ -161,6 +161,10 @@ function Get-NightlyFeedFreshness { matched = $true } } catch { + # Fail-open: a network/parse error yields $null so the caller renders a muted + # "unknown" banner rather than crashing the unattended job. Surface the reason to + # the CI log so a real feed outage (401/503/DNS) isn't silently invisible. + Write-Warning "Nightly-feed query failed for feed '$Feed' (fail-open -> unknown): $($_.Exception.Message)" return $null } } @@ -223,6 +227,16 @@ function Resolve-NightlyDogfoodFreshness { # (e.g. a preview feed whose newest bits are its preview.N builds). $band = Get-NightlyFeedFreshness @common -VersionPrefixRegex $BandPrefixRegex if ($null -eq $band) { return @{ unknown = $true } } + # Never surface a ci.main build as the dogfood signal. An SR lane's band prefix + # (e.g. ^10\.0\.90-) also matches the always-fresh ci.main stream, so a no-inflight + # window (start of an SR cycle, or inflight-label churn) would otherwise return a fresh + # ci.main build and paint a stalled inflight feed green — the exact false-positive this + # resolver exists to prevent. Report matched=$false (muted "no matching build") instead. + # Preview bands (preview.N) never match ci.main, so this is a no-op for the preview lane. + $bandVer = [string](Get-NightlyFeedProp $band 'version') + if ($bandVer -match 'ci\.main\.') { + return @{ feed = $Feed; package = $Package; matched = $false; buildType = 'band' } + } $band['buildType'] = 'band' return $band } diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index 7acbdd83a616..0773a97a2012 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -3577,6 +3577,46 @@ try { Set-Item function:Get-NightlyFeedFreshness $nfOrigDef # restore real helper } +# (e) FALSE-GREEN GUARD: feed has NO inflight builds and the newest band build is a fresh +# ci.main build. An SR lane's band prefix (^10\.0\.90-) matches ci.main too, so without the +# exclusion the resolver would surface that fresh ci.main build and paint a stalled inflight +# feed green. It must instead report matched=$false (muted "no matching build"). +$nfCiMainOnlyMock = { + param($Url) + if ($Url -match '_packaging/.+/nuget/v3/index\.json$') { + return [pscustomobject]@{ resources = @([pscustomobject]@{ '@type'='RegistrationsBaseUrl/3.6.0'; '@id'='https://reg.example/3.6.0/' }) } + } + if ($Url -match '/3\.6\.0/.+/index\.json$') { + $mk = { param($v,$p) [pscustomobject]@{ catalogEntry = [pscustomobject]@{ version=$v; published=$p } } } + return [pscustomobject]@{ items = @([pscustomobject]@{ items = @( + (& $mk '10.0.90-ci.main.123' '2026-06-22T03:00:00Z') # fresh, but ci.main — NOT a dogfood signal + ) }) } + } + throw "unexpected url $Url" +} +$rCiMain = Resolve-NightlyDogfoodFreshness -Feed 'dotnet10' -BandPrefixRegex '^10\.0\.90-' -Fetcher $nfCiMainOnlyMock +Assert-Eq -Label "resolve: band fallback ci.main-only → matched false (no false-green)" -Expected $false -Actual ([bool](Get-NightlyFeedProp $rCiMain 'matched')) +Assert-Eq -Label "resolve: band fallback ci.main-only → no version surfaced" -Expected $true -Actual ([string]::IsNullOrEmpty([string](Get-NightlyFeedProp $rCiMain 'version'))) + +# (f) The exclusion must NOT over-filter: a non-ci.main band build (rc/servicing/rtm) is a +# legitimate fallback signal and must still surface. +$nfRcMock = { + param($Url) + if ($Url -match '_packaging/.+/nuget/v3/index\.json$') { + return [pscustomobject]@{ resources = @([pscustomobject]@{ '@type'='RegistrationsBaseUrl/3.6.0'; '@id'='https://reg.example/3.6.0/' }) } + } + if ($Url -match '/3\.6\.0/.+/index\.json$') { + $mk = { param($v,$p) [pscustomobject]@{ catalogEntry = [pscustomobject]@{ version=$v; published=$p } } } + return [pscustomobject]@{ items = @([pscustomobject]@{ items = @( + (& $mk '10.0.90-rc.1.456' '2026-06-22T03:00:00Z') + ) }) } + } + throw "unexpected url $Url" +} +$rRc = Resolve-NightlyDogfoodFreshness -Feed 'dotnet10' -BandPrefixRegex '^10\.0\.90-' -Fetcher $nfRcMock +Assert-Eq -Label "resolve: band fallback non-ci.main build still surfaces" -Expected '10.0.90-rc.1.456' -Actual $rRc.version +Assert-Eq -Label "resolve: band fallback non-ci.main → buildType band" -Expected 'band' -Actual $rRc.buildType + # ───── Get-NightlyFeedTier (banner-state bucketing for the idempotency hash) ───── Write-Host "`n[Unit] Get-NightlyFeedTier (stable banner-state bucket)" -ForegroundColor Cyan $tierNow = [datetime]::new(2026, 6, 22, 0, 0, 0, [System.DateTimeKind]::Utc) @@ -3632,6 +3672,25 @@ Assert-Eq -Label "hash: feed present vs absent → DIFFERENT" -Expected $false - Assert-Eq -Label "hash: unknown tier vs ok → DIFFERENT" -Expected $false -Actual ($hOk -eq $hUnk) Assert-Eq -Label "hash: nightly-feed fold is deterministic" -Expected $hStale -Actual (Get-ReportSemanticHash -Data $dStale -Verdict $nfV) +# Split-clock guard: the hash must derive the tier from the render-time instant stored in +# $Data['nightlyFeedNow'] (the same instant the banner used), NOT a fresh wall-clock sample. +# Two records with IDENTICAL feed data but different stored "now" (one age→ok, one age→stale) +# must therefore hash DIFFERENTLY. Pre-fix the hash sampled [datetime]::UtcNow and ignored the +# stored now, so both collapsed to the same tier+hash and the banner could freeze across a +# boundary. (Regression guard for the banner/hash boundary-straddle bug.) +$nfSplitPub = [datetime]::new(2026, 6, 1, 0, 0, 0, [System.DateTimeKind]::Utc) +$dNowOk = & $nfBase +$dNowOk['nightlyFeed'] = @{ matched = $true; version = '10.0.90-ci.inflight.1'; published = $nfSplitPub } +$dNowOk['nightlyFeedNow'] = $nfSplitPub.AddDays(1) # age 1 → ok +$dNowStale = & $nfBase +$dNowStale['nightlyFeed'] = @{ matched = $true; version = '10.0.90-ci.inflight.1'; published = $nfSplitPub } +$dNowStale['nightlyFeedNow'] = $nfSplitPub.AddDays(10) # age 10 → stale (SAME data, different stored now) +$hNowOk = Get-ReportSemanticHash -Data $dNowOk -Verdict $nfV +$hNowStale = Get-ReportSemanticHash -Data $dNowStale -Verdict $nfV +Assert-Eq -Label "hash: honors stored nightlyFeedNow (ok@T1 vs stale@T2 → DIFFERENT)" -Expected $false -Actual ($hNowOk -eq $hNowStale) +Assert-Eq -Label "hash: stored-now tier resolves to ok at T1" -Expected 'ok' -Actual (Get-NightlyFeedTier -Freshness $dNowOk['nightlyFeed'] -Now $dNowOk['nightlyFeedNow']) +Assert-Eq -Label "hash: stored-now tier resolves to stale at T2" -Expected 'stale' -Actual (Get-NightlyFeedTier -Freshness $dNowStale['nightlyFeed'] -Now $dNowStale['nightlyFeedNow']) + Write-Host "`n────────────────────────────────────────" -ForegroundColor Cyan Write-Host "Passed: $script:passed Failed: $script:failed" -ForegroundColor $(if ($script:failed -eq 0) { 'Green' } else { 'Red' }) exit $(if ($script:failed -eq 0) { 0 } else { 1 }) From 80dede24e00d9424b298e82573c5ad9b14cf23f9 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:57:19 -0500 Subject: [PATCH 5/9] Harden nightly-feed banner: fail-open under WarningPreference=Stop + preview label parity Round-2 adversarial-review follow-ups on the nightly dogfood feed staleness banner: - NightlyFeed.ps1: the fail-open catch in Get-NightlyFeedFreshness now emits its diagnostic with -WarningAction Continue. Under an ambient $WarningPreference='Stop' (or inherited -WarningAction Stop) the bare Write-Warning would raise a terminating error that escapes the helper, violating its documented 'never throws -> returns $null' contract and breaking fail-open for the unattended nightly job. - Get-PreviewReadiness.ps1: mirror the SR lane's 3-branch honest-label logic so an unknown / transient-inflight-failure case is labelled ci.inflight (the stream being measured) instead of implying the preview band carries the signal. - Adds a regression assert that Get-NightlyFeedFreshness with a throwing fetcher under $WarningPreference='Stop' still returns $null without throwing. Offline suite 625/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Get-PreviewReadiness.ps1 | 7 ++++++- .../release-readiness/scripts/NightlyFeed.ps1 | 5 ++++- .../tests/Test-ReleaseReadiness.ps1 | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 index eec13b2bfb2f..b444dca47517 100644 --- a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 @@ -1383,7 +1383,12 @@ if ($Script:NightlyFeedHelperLoaded -and if ($null -eq $nfFresh) { $nfFresh = @{ unknown = $true } } $nfBuildType = [string](Get-NightlyFeedProp $nfFresh 'buildType') - $nfTypeNote = if ($nfBuildType -eq 'inflight') { 'ci.inflight' } else { "``$nfBand`` (preview.$nfIteration)" } + # Label honestly, mirroring the SR lane: 'inflight' -> ci.inflight; a definitive band + # fallback -> the band; anything else (unknown / transient inflight failure) -> ci.inflight, + # the stream we were measuring -- never imply the band carries the signal when unknown. + $nfTypeNote = if ($nfBuildType -eq 'inflight') { 'ci.inflight' } + elseif ($nfBuildType -eq 'band') { "``$nfBand`` (preview.$nfIteration)" } + else { 'ci.inflight' } $nfLaneLabel = "[``$nfFeed``]($nfFeedUrl) · $nfTypeNote" $nfFresh['laneLabel'] = $nfLaneLabel $nfFresh['feedUrl'] = $nfFeedUrl diff --git a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 index 7c78af27ad6a..b0e47113ada1 100644 --- a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 +++ b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 @@ -164,7 +164,10 @@ function Get-NightlyFeedFreshness { # Fail-open: a network/parse error yields $null so the caller renders a muted # "unknown" banner rather than crashing the unattended job. Surface the reason to # the CI log so a real feed outage (401/503/DNS) isn't silently invisible. - Write-Warning "Nightly-feed query failed for feed '$Feed' (fail-open -> unknown): $($_.Exception.Message)" + # -WarningAction Continue keeps fail-open intact even if an ambient + # $WarningPreference='Stop' (or -WarningAction Stop) would otherwise turn this + # diagnostic into a terminating error and break the "never throws" contract. + Write-Warning "Nightly-feed query failed for feed '$Feed' (fail-open -> unknown): $($_.Exception.Message)" -WarningAction Continue return $null } } diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index 0773a97a2012..81aff05dbe3c 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -3499,6 +3499,22 @@ Assert-Eq -Label "feed: band with no build → matched is false" -Expected $fals $rThrow = Get-NightlyFeedFreshness -Feed 'dotnet10' -VersionPrefixRegex '^10\.0\.90-' -Fetcher { param($Url) throw 'boom' } Assert-Eq -Label "feed: fetcher throws → null (fail-open)" -Expected $true -Actual ($null -eq $rThrow) +# Fail-open holds even under an ambient $WarningPreference='Stop': the diagnostic Write-Warning +# in the catch must not turn into a terminating error that escapes the helper. The catch uses +# -WarningAction Continue so the "never throws" contract survives a Stop preference. +$rStopWarn = $null +$nfStopThrew = $false +$nfPrevWarnPref = $WarningPreference +try { + $WarningPreference = 'Stop' + $rStopWarn = Get-NightlyFeedFreshness -Feed 'dotnet10' -VersionPrefixRegex '^10\.0\.90-' -Fetcher { param($Url) throw 'boom' } +} catch { + $nfStopThrew = $true +} finally { + $WarningPreference = $nfPrevWarnPref +} +Assert-Eq -Label "feed: fetcher throws under WarningPreference=Stop → still null, no throw (fail-open)" -Expected $true -Actual (($null -eq $rStopWarn) -and (-not $nfStopThrew)) + # Paged registration: a page that carries only an @id (no inline items) is followed. $nfPaged = { param($Url) From 9b915f679016ddedfe9aacac4e5795ef1f97d7ff Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Mon, 22 Jun 2026 16:09:06 -0500 Subject: [PATCH 6/9] Propagate fail-open WarningPreference=Stop hardening to engine catches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 adversarial review (opus/gpt-5.5/gemini all 3 flagged this) found the round-2 fail-open fix was incomplete: only the NightlyFeed.ps1 helper's inner catch was hardened. The two engine scripts' OUTER nightly-feed catches — and the load-time 'helper not found' warnings — still used a bare Write-Warning. Under an ambient $WarningPreference='Stop', a non-helper error inside the engine try (band resolution, [int] cast on a garbled Versions.props, banner formatting) hits the catch, the bare Write-Warning is promoted to a terminating error, and it escapes — crashing the unattended nightly job. Same contract the round-2 fix protects, just one frame up. - Get-ReleaseReadiness.ps1: -WarningAction Continue on the Add-SrNightlyFeedFreshness outer catch (line ~3701) and the load-time helper-not-found warning (line ~171). - Get-PreviewReadiness.ps1: same two sites (lines ~1403 and ~137). - Adds an engine-boundary regression test that drives a throw through Add-SrNightlyFeedFreshness under $WarningPreference='Stop' and asserts the catch swallows it (fails on pre-fix bare Write-Warning). Offline suite 626/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Get-PreviewReadiness.ps1 | 7 +++-- .../scripts/Get-ReleaseReadiness.ps1 | 7 +++-- .../tests/Test-ReleaseReadiness.ps1 | 29 +++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 index b444dca47517..417c8ada491f 100644 --- a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 @@ -134,7 +134,7 @@ if (Test-Path $nightlyFeedHelperPath) { . $nightlyFeedHelperPath $Script:NightlyFeedHelperLoaded = $true } else { - Write-Warning "NightlyFeed.ps1 helper not found at $nightlyFeedHelperPath — nightly-feed banner disabled." + Write-Warning "NightlyFeed.ps1 helper not found at $nightlyFeedHelperPath — nightly-feed banner disabled." -WarningAction Continue } # =================================================================== @@ -1397,7 +1397,10 @@ if ($Script:NightlyFeedHelperLoaded -and $report.NightlyFeed = $nfFresh $nightlyFeedBanner = Format-NightlyFeedBanner -Freshness $nfFresh -Now ([DateTime]::UtcNow) } catch { - Write-Warning "Nightly-feed freshness check failed (non-fatal): $($_.Exception.Message)" + # -WarningAction Continue: keep this fail-open even under an ambient + # $WarningPreference='Stop', where a bare Write-Warning would be promoted to a + # terminating error inside the catch and escape, crashing the unattended job. + Write-Warning "Nightly-feed freshness check failed (non-fatal): $($_.Exception.Message)" -WarningAction Continue } } diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index 3fb50e7494ca..9f6a46653c5b 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -168,7 +168,7 @@ if (Test-Path $nightlyFeedHelperPath) { . $nightlyFeedHelperPath $Script:NightlyFeedHelperLoaded = $true } else { - Write-Warning "NightlyFeed.ps1 helper not found at $nightlyFeedHelperPath — nightly-feed banner disabled." + Write-Warning "NightlyFeed.ps1 helper not found at $nightlyFeedHelperPath — nightly-feed banner disabled." -WarningAction Continue } # DETERMINISTIC RULE — SR branches in dotnet/maui ALWAYS cut from `main`. @@ -3695,7 +3695,10 @@ function Add-SrNightlyFeedFreshness { $banner = Format-NightlyFeedBanner -Freshness $fresh -Now $nfNow if ($banner) { $Data['nightlyFeedBanner'] = $banner } } catch { - Write-Warning "Nightly-feed freshness check failed (non-fatal): $($_.Exception.Message)" + # -WarningAction Continue: keep this fail-open even under an ambient + # $WarningPreference='Stop', where a bare Write-Warning would be promoted to a + # terminating error inside the catch and escape, crashing the unattended job. + Write-Warning "Nightly-feed freshness check failed (non-fatal): $($_.Exception.Message)" -WarningAction Continue } } diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index 81aff05dbe3c..d43d4fc6b22c 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -3707,6 +3707,35 @@ Assert-Eq -Label "hash: honors stored nightlyFeedNow (ok@T1 vs stale@T2 → DIFF Assert-Eq -Label "hash: stored-now tier resolves to ok at T1" -Expected 'ok' -Actual (Get-NightlyFeedTier -Freshness $dNowOk['nightlyFeed'] -Now $dNowOk['nightlyFeedNow']) Assert-Eq -Label "hash: stored-now tier resolves to stale at T2" -Expected 'stale' -Actual (Get-NightlyFeedTier -Freshness $dNowStale['nightlyFeed'] -Now $dNowStale['nightlyFeedNow']) +# ───── Engine-level fail-open under WarningPreference=Stop ───── +# The helper's inner catch is hardened, but the SR engine's OUTER catch in +# Add-SrNightlyFeedFreshness wraps non-helper work (band resolution, formatting) that can +# also throw. Under an ambient $WarningPreference='Stop', a bare Write-Warning in that catch +# would be promoted to a terminating error that escapes and crashes the unattended job — the +# same contract the helper fix protects, one frame up. Drive a throw from inside the try and +# assert the engine swallows it. (Regression guard; fails on pre-fix bare Write-Warning.) +Write-Host "`n[Unit] Engine-level nightly-feed fail-open (WarningPreference=Stop)" -ForegroundColor Cyan +$nfEngThrew = $false +$nfEngPrevWarn = $WarningPreference +$nfEngOrigVps = (Get-Item function:Get-VersionsPropsState).ScriptBlock +$nfEngOrigResolve = (Get-Item function:Resolve-NightlyDogfoodFreshness).ScriptBlock +$nfEngOrigLoaded = $Script:NightlyFeedHelperLoaded +try { + $Script:NightlyFeedHelperLoaded = $true + function Get-VersionsPropsState { param($Ref) @{ Major = 10; Patch = 0 } } + function Resolve-NightlyDogfoodFreshness { throw 'simulated band-resolution failure' } + $WarningPreference = 'Stop' + Add-SrNightlyFeedFreshness -Data @{ metadata = @{ srRef = 'release/10.0.1xx-sr1' } } +} catch { + $nfEngThrew = $true +} finally { + $WarningPreference = $nfEngPrevWarn + Set-Item function:Get-VersionsPropsState $nfEngOrigVps + Set-Item function:Resolve-NightlyDogfoodFreshness $nfEngOrigResolve + $Script:NightlyFeedHelperLoaded = $nfEngOrigLoaded +} +Assert-Eq -Label "engine: Add-SrNightlyFeedFreshness catch survives WarningPreference=Stop (fail-open)" -Expected $true -Actual (-not $nfEngThrew) + Write-Host "`n────────────────────────────────────────" -ForegroundColor Cyan Write-Host "Passed: $script:passed Failed: $script:failed" -ForegroundColor $(if ($script:failed -eq 0) { 'Green' } else { 'Red' }) exit $(if ($script:failed -eq 0) { 0 } else { 1 }) From df6094538b0ce0226e8501a027b4463d02f3cc38 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:08:51 -0500 Subject: [PATCH 7/9] Extract shared Format-NightlyFeedLaneLabel helper + document NightlyFeed in SKILL.md Addresses the two non-blocking suggestions from the bot review on #36066: - Dedupe the nightly-feed lane-label logic that was copy-pasted between the SR (Get-ReleaseReadiness.ps1) and Preview (Get-PreviewReadiness.ps1) engines into a single PURE helper, Format-NightlyFeedLaneLabel, in NightlyFeed.ps1. The honest-labeling rule (inflight->ci.inflight; band->caller-formatted note; unknown->ci.inflight) now has one source of truth, so the lanes can't drift the way the preview lane silently did once. Adds 5 direct unit tests covering both band-note shapes plus the unknown/other fallback (a mutation reintroducing the drift fails them). Offline suite 631/0. - Document NightlyFeed.ps1 (functions, fail-open contract, determinism) and the staleness banner in the release-readiness SKILL.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/release-readiness/SKILL.md | 16 +++++++ .../scripts/Get-PreviewReadiness.ps1 | 8 +--- .../scripts/Get-ReleaseReadiness.ps1 | 8 +--- .../release-readiness/scripts/NightlyFeed.ps1 | 45 ++++++++++++++++++- .../tests/Test-ReleaseReadiness.ps1 | 21 +++++++++ 5 files changed, 83 insertions(+), 15 deletions(-) diff --git a/.github/skills/release-readiness/SKILL.md b/.github/skills/release-readiness/SKILL.md index 85d0f0a9e866..6a62fe2a4bce 100644 --- a/.github/skills/release-readiness/SKILL.md +++ b/.github/skills/release-readiness/SKILL.md @@ -261,6 +261,21 @@ Three critical gotchas this skill encodes — see [references/methodology.md](re This skill depends on `.github/scripts/shared/MauiReleaseVersioning.psm1` for canonical milestone/version parsing (e.g. `Get-CurrentMajorVersion`, `ConvertBranchToMilestone`, `Get-MilestoneSortKey`, `Compare-MauiMilestone`). The module is also consumed by `Fix-MilestoneDrift.ps1` to keep milestone classification consistent across all release-related automation. +### `scripts/NightlyFeed.ps1` (nightly dogfood feed staleness banner) + +Both engines dot-source [`scripts/NightlyFeed.ps1`](scripts/NightlyFeed.ps1) to surface a one-line **nightly dogfood feed freshness banner** at the top of each tracker (just under **Generated**). The point of the banner is to make it obvious when the dogfood bits people are told to test have stopped flowing — e.g. when the `ci.inflight` pipeline is red, the feed goes stale and the banner turns ❌ so consumers don't waste time validating against builds that never updated. + +Key functions (all PURE except the one network call, which is **fail-open** — any feed error returns `$null` / renders a muted "freshness unknown" note and never breaks tracker generation): + +| Function | Purpose | +|----------|---------| +| `Get-NightlyFeedFreshness` | Queries an Azure Artifacts NuGet feed (`dotnet10`, `dotnet11`, …) for the newest **published** build matching a version-prefix regex; returns version + publish date. Injectable `-Fetcher` for offline tests. | +| `Resolve-NightlyDogfoodFreshness` | Picks the stream that matters: **`ci.inflight` first** (the "shipping next" dogfood bits), the lane band only as a fallback. Conservatively returns `matched=$false` when *only* `ci.main` exists, so a daily main build never paints a false green. | +| `Format-NightlyFeedLaneLabel` | PURE builder for the `` [`feed`](url) · `` lane label. Centralizes the honest-labeling rule (`inflight`→`ci.inflight`; `band`→caller-formatted band note; unknown→`ci.inflight`) so the SR and Preview lanes can't drift. | +| `Get-NightlyFeedTier` / `Format-NightlyFeedBanner` | Bucket age into ✅ ≤2d · ⚠️ 3–6d · ❌ ≥7d and render the markdown banner. Both take an explicit `-Now`, so they're deterministic and unit-testable offline. | + +Determinism / idempotency: the engine captures **one** `UtcNow` per run (`$Data['nightlyFeedNow']`) and reuses it for both the rendered banner and the semantic-hash tier, so a quiet SR tracker still refreshes when the feed crosses a tier boundary, but a same-tier day-count tick does **not** churn the issue. The freshness band is folded into `Get-ReportSemanticHash` (tier|version only — the raw timestamp is never hashed). + ## Integration - **Custom agent**: `.github/agents/release-readiness-agent.agent.md` wraps this skill — handles regression-label confirmation, runs the script, then uses WorkIQ to add context for `rejected-from-sr` PRs. @@ -291,3 +306,4 @@ The harness covers: - **`-AllActiveMajors`** end-to-end across net10 + net11 with the expected tracker counts - **`Get-ReleaseReadiness`** verdict classification using known-answer data from the SR7 readiness analysis (e.g. #35313 → `in-sr-active`, #35344 → `in-sr-active` via the SafeArea follow-on fix, #35771 → `no-fix-yet`) - **Idempotent body hash** stability across re-runs — **SR trackers only** (the daily workflow compares the embedded `` marker against the live issue and skips the edit when the semantic content is unchanged, so re-runs don't churn the tracker). Preview trackers carry no hash marker and are refreshed on every scheduled run. +- **Nightly dogfood feed banner** (`NightlyFeed.ps1`) — offline unit coverage for the lane-label honest-labeling rule (`Format-NightlyFeedLaneLabel`), the `ci.inflight`-first / `ci.main`-false-green resolver, age→tier bucketing, the fail-open feed query (mocked `-Fetcher`), and the banner's fold into `Get-ReportSemanticHash` (tier change refreshes, same-tier day tick does not). All network-free via injected fixtures and explicit `-Now`. diff --git a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 index 417c8ada491f..48e038d8c96e 100644 --- a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 @@ -1383,13 +1383,7 @@ if ($Script:NightlyFeedHelperLoaded -and if ($null -eq $nfFresh) { $nfFresh = @{ unknown = $true } } $nfBuildType = [string](Get-NightlyFeedProp $nfFresh 'buildType') - # Label honestly, mirroring the SR lane: 'inflight' -> ci.inflight; a definitive band - # fallback -> the band; anything else (unknown / transient inflight failure) -> ci.inflight, - # the stream we were measuring -- never imply the band carries the signal when unknown. - $nfTypeNote = if ($nfBuildType -eq 'inflight') { 'ci.inflight' } - elseif ($nfBuildType -eq 'band') { "``$nfBand`` (preview.$nfIteration)" } - else { 'ci.inflight' } - $nfLaneLabel = "[``$nfFeed``]($nfFeedUrl) · $nfTypeNote" + $nfLaneLabel = Format-NightlyFeedLaneLabel -Feed $nfFeed -FeedUrl $nfFeedUrl -BuildType $nfBuildType -BandNote "``$nfBand`` (preview.$nfIteration)" $nfFresh['laneLabel'] = $nfLaneLabel $nfFresh['feedUrl'] = $nfFeedUrl $nfFresh['versionPrefix'] = $nfBandPrefix diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index 9f6a46653c5b..176b7a3b0c98 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -3675,13 +3675,7 @@ function Add-SrNightlyFeedFreshness { if ($null -eq $fresh) { $fresh = @{ unknown = $true } } $buildType = [string](Get-NightlyFeedProp $fresh 'buildType') - # Label honestly: 'inflight' → ci.inflight; a definitive band fallback → the band; - # anything else (unknown / transient inflight failure) → ci.inflight, the stream we - # were measuring — never imply the band carries the signal when freshness is unknown. - $typeNote = if ($buildType -eq 'inflight') { 'ci.inflight' } - elseif ($buildType -eq 'band') { "``$band``" } - else { 'ci.inflight' } - $laneLabel = "[``$feed``]($feedUrl) · $typeNote" + $laneLabel = Format-NightlyFeedLaneLabel -Feed $feed -FeedUrl $feedUrl -BuildType $buildType -BandNote "``$band``" $fresh['laneLabel'] = $laneLabel $fresh['feedUrl'] = $feedUrl $fresh['versionPrefix'] = $bandPrefix diff --git a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 index b0e47113ada1..f3fd3967a440 100644 --- a/.github/skills/release-readiness/scripts/NightlyFeed.ps1 +++ b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 @@ -5,7 +5,7 @@ Nightly-feed freshness helpers shared by the SR and Preview release-readiness engines. .DESCRIPTION - Two functions: + Headline functions: Get-NightlyFeedFreshness — queries an Azure Artifacts NuGet feed (e.g. dotnet10, dotnet11) for the newest published build of a package @@ -14,6 +14,10 @@ FAIL-OPEN: any error returns $null so a transient feed outage never breaks tracker generation. + Format-NightlyFeedLaneLabel — PURE builder for the "[`feed`](url) · " lane + label, centralizing the honest-labeling rule shared by both + engines so the SR and Preview lanes can never drift. + Format-NightlyFeedBanner — PURE, deterministic renderer that turns a freshness record into a one-line markdown banner (✅ fresh / ⚠️ aging / ❌ stale / muted unknown). No network, no @@ -289,6 +293,45 @@ function Get-NightlyFeedTier { return 'ok' } +function Format-NightlyFeedLaneLabel { + <# + .SYNOPSIS + Build the "[``]() · " lane label shown in the banner, applying the + honest-labeling rule shared by the SR and Preview engines. PURE: no network, no clock. + .DESCRIPTION + Centralizes the build-type → label mapping so the two engines can never drift (the + preview lane silently lost the band branch once; this is the single source of truth): + + - 'inflight' → 'ci.inflight' (the primary dogfood stream we measure) + - 'band' → $BandNote (a definitive band fallback — caller-formatted, since + SR shows just the band while preview appends the + preview iteration) + - anything else → 'ci.inflight' (unknown / transient inflight-query failure: name the + stream we were MEASURING, never imply the band carries + the signal when freshness is unknown) + .PARAMETER Feed + The feed short name (e.g. 'dotnet10'), rendered as a code-fenced link label. + .PARAMETER FeedUrl + The feed's Azure Artifacts URL. + .PARAMETER BuildType + The resolved build type: 'inflight', 'band', or '' / unknown. + .PARAMETER BandNote + The already-formatted markdown to display when $BuildType is 'band'. The engines differ + here (SR shows just the band, e.g. '`10.0.80`'; preview appends the iteration, e.g. + '`11.0.0-preview.6` (preview.6)'), so the caller supplies it pre-formatted. + #> + param( + [Parameter(Mandatory)][string]$Feed, + [Parameter(Mandatory)][string]$FeedUrl, + [string]$BuildType, + [string]$BandNote + ) + $typeNote = if ($BuildType -eq 'inflight') { 'ci.inflight' } + elseif ($BuildType -eq 'band') { $BandNote } + else { 'ci.inflight' } + "[``$Feed``]($FeedUrl) · $typeNote" +} + function Format-NightlyFeedBanner { <# .SYNOPSIS diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index d43d4fc6b22c..7b32c0b4f9ed 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -3649,6 +3649,27 @@ Assert-Eq -Label "tier: age 6 (< stale 7) → aging" -Expected 'aging' - Assert-Eq -Label "tier: age 7 (= stale) → stale" -Expected 'stale' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = (New-NfPub 7) } -Now $tierNow) Assert-Eq -Label "tier: age 15 → stale" -Expected 'stale' -Actual (Get-NightlyFeedTier -Freshness @{ matched = $true; version = 'x'; published = (New-NfPub 15) } -Now $tierNow) +# ───── Format-NightlyFeedLaneLabel: honest-labeling rule (shared by both engines) ───── +# Direct guard for the rule that drifted once (the preview lane silently lost the band +# branch). Both engines now call this single helper, so these asserts cover both lanes. +Write-Host "`n[Unit] Format-NightlyFeedLaneLabel (honest labeling)" -ForegroundColor Cyan +$llFeed = 'dotnet10'; $llUrl = 'https://dev.azure.com/x' +Assert-Eq -Label "lane: inflight → ci.inflight" ` + -Expected '[`dotnet10`](https://dev.azure.com/x) · ci.inflight' ` + -Actual (Format-NightlyFeedLaneLabel -Feed $llFeed -FeedUrl $llUrl -BuildType 'inflight' -BandNote '`10.0.80`') +Assert-Eq -Label "lane: band (SR shape) → band note" ` + -Expected '[`dotnet10`](https://dev.azure.com/x) · `10.0.80`' ` + -Actual (Format-NightlyFeedLaneLabel -Feed $llFeed -FeedUrl $llUrl -BuildType 'band' -BandNote '`10.0.80`') +Assert-Eq -Label "lane: band (preview shape) → band note w/ iteration" ` + -Expected '[`dotnet11`](https://dev.azure.com/x) · `11.0.0-preview.6` (preview.6)' ` + -Actual (Format-NightlyFeedLaneLabel -Feed 'dotnet11' -FeedUrl $llUrl -BuildType 'band' -BandNote '`11.0.0-preview.6` (preview.6)') +Assert-Eq -Label "lane: unknown buildType → ci.inflight (never the band)" ` + -Expected '[`dotnet10`](https://dev.azure.com/x) · ci.inflight' ` + -Actual (Format-NightlyFeedLaneLabel -Feed $llFeed -FeedUrl $llUrl -BuildType '' -BandNote '`10.0.80`') +Assert-Eq -Label "lane: other buildType → ci.inflight (honest fallback)" ` + -Expected '[`dotnet10`](https://dev.azure.com/x) · ci.inflight' ` + -Actual (Format-NightlyFeedLaneLabel -Feed $llFeed -FeedUrl $llUrl -BuildType 'mystery' -BandNote '`10.0.80`') + # ───── Get-ReportSemanticHash folds in nightly-feed banner state ───── # Regression guard for the idempotency bug: a quiet SR tracker whose ONLY change is the # nightly feed going stale must still refresh (the banner is the point of the feature), From 306a2962da74b3022e16c0ea8080b543b0e5d3b4 Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Tue, 23 Jun 2026 15:09:07 -0500 Subject: [PATCH 8/9] Render closed no-fix-yet regressions under Tier 3 (display/count parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closed `no-fix-yet` regressions were counted in the SR Summary table but rendered in no tier: `no-fix-yet` lived only in $tier1Classes and the emit filtered it to OPEN, while it was absent from $tier3Classes. So closed entries (triaged-away regressions with no fix PR cross-referenced) showed as e.g. "no-fix-yet: 6" in the summary yet appeared nowhere — the live symptom on tracker #35876 (.NET 10 SR8). The verdict logic already downgrades closed no-fix-yet to Tier 3 (Get-VerdictTier path), so display and verdict disagreed. Make the tier emit state-aware: OPEN no-fix-yet block in Tier 1, CLOSED-but-unresolved ones render as Tier 3 informational — so the Summary count and the displayed rows agree and closed-but-unresolved regressions stay visible for the release captain. Adds a discriminating regression test (open → Tier 1, closed → Tier 3, closed NOT in Tier 1). Offline suite 635/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Get-ReleaseReadiness.ps1 | 21 ++++++++---- .../tests/Test-ReleaseReadiness.ps1 | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index 176b7a3b0c98..d9d5ff1e9cab 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -3511,16 +3511,23 @@ function Format-MarkdownReport { $tier1Classes = @('in-sr-reverted', 'no-fix-yet') | Sort-Object $tier2Classes = @('rejected-from-sr', 'backport-in-progress', 'merged-on-main-no-backport', 'merged-non-main-only', 'open-on-main', 'needs-human-review') | Sort-Object - $tier3Classes = @('in-sr-active', 'closed-as-duplicate', 'out-of-scope-future-sr') | Sort-Object + $tier3Classes = @('in-sr-active', 'closed-as-duplicate', 'no-fix-yet', 'out-of-scope-future-sr') | Sort-Object $emitTier = { - param([string]$Header, [string[]]$Classes, [string]$EmptyLine) + param([string]$Header, [string[]]$Classes, [string]$EmptyLine, [string]$NoFixYetState) $any = $false foreach ($cls in $Classes) { $items = @($regs | Where-Object { $_.classification -eq $cls }) - # In Tier 1 we suppress no-fix-yet entries whose issue is CLOSED + # no-fix-yet splits by issue state to mirror the verdict tiering + # (the Get-VerdictTier downgrade): OPEN ones block (Tier 1), CLOSED-but- + # unresolved ones are informational (Tier 3). Without this split the closed + # entries are counted in the Summary yet rendered in no tier at all. if ($cls -eq 'no-fix-yet') { - $items = @($items | Where-Object { $_.state -eq 'OPEN' }) + if ($NoFixYetState -eq 'OPEN') { + $items = @($items | Where-Object { $_.state -eq 'OPEN' }) + } elseif ($NoFixYetState -eq 'CLOSED') { + $items = @($items | Where-Object { $_.state -ne 'OPEN' }) + } } if ($items.Count -eq 0) { continue } if (-not $any) { @@ -3551,9 +3558,9 @@ function Format-MarkdownReport { } } - & $emitTier '🔴 Tier 1 — Blocking' $tier1Classes '_No blocking regressions._' - & $emitTier '🟡 Tier 2 — Risk / Review' $tier2Classes '_No risk-tier regressions._' - & $emitTier '🟢 Tier 3 — Informational' $tier3Classes $null + & $emitTier '🔴 Tier 1 — Blocking' $tier1Classes '_No blocking regressions._' 'OPEN' + & $emitTier '🟡 Tier 2 — Risk / Review' $tier2Classes '_No risk-tier regressions._' $null + & $emitTier '🟢 Tier 3 — Informational' $tier3Classes $null 'CLOSED' } $body = $sb.ToString() diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index 7b32c0b4f9ed..d1bd406a3537 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -1847,6 +1847,40 @@ $ofRow = @($mdOpenFix -split "`r?`n" | Where-Object { $_ -match '🔵 OPEN — a Assert-Eq -Label "Open-Fix-PRs table: regression-issue cell glued + pipe-escaped, status column intact" -Expected $true ` -Actual ($ofRow.Count -eq 1 -and $ofRow[0] -match 'Glitch \\\| bug here') +# (4b) Closed no-fix-yet renders under Tier 3 (not silently dropped). +# no-fix-yet splits by issue state to mirror the verdict tiering: OPEN ones block +# (Tier 1), CLOSED-but-unresolved ones are informational (Tier 3). Pre-fix, closed +# no-fix-yet were counted in the Summary yet rendered in NO tier — the live symptom on +# tracker #35876: "no-fix-yet: 6" in the summary with 0 shown in any tier. This test is +# DISCRIMINATING: the CLOSED-in-Tier-3 assertion is false pre-fix (entry dropped) and the +# CLOSED-not-in-Tier-1 assertion guards against regressing it back into the blocking tier. +$mdDataNfy = @{} + $mdData +$mdDataNfy['regressions'] = @( + @{ issue = 96201; title = 'Open regression, no fix PR'; state = 'OPEN'; classification = 'no-fix-yet'; + candidateFixPrs = @(); recommendedAction = 'Investigate' } + @{ issue = 96202; title = 'Closed regression, no fix PR found'; state = 'CLOSED'; classification = 'no-fix-yet'; + candidateFixPrs = @(); recommendedAction = 'Verify resolved' } +) +$mdDataNfy['summary'] = @{ 'no-fix-yet' = 2 } +$mdNfy = Format-MarkdownReport -Data $mdDataNfy -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "no-fix-yet split: Tier 3 section is present (closed entry surfaced it)" -Expected $true ` + -Actual ($mdNfy -match '🟢 Tier 3') +# Carve the body into tier regions by header position so the top blocking-summary table +# (which sits BEFORE Tier 1) cannot leak into the Tier-1 region assertions. +$nfyLines = @($mdNfy -split "`r?`n") +$idxT1 = ($nfyLines | Select-String -Pattern '🔴 Tier 1' | Select-Object -First 1).LineNumber - 1 +$idxT2 = ($nfyLines | Select-String -Pattern '🟡 Tier 2' | Select-Object -First 1).LineNumber - 1 +$idxT3 = ($nfyLines | Select-String -Pattern '🟢 Tier 3' | Select-Object -First 1).LineNumber - 1 +$tier1Block = ($nfyLines[$idxT1..($idxT2 - 1)] -join "`n") +$tier3Block = ($nfyLines[$idxT3..($nfyLines.Count - 1)] -join "`n") +Assert-Eq -Label "OPEN no-fix-yet (#96201) renders in Tier 1" -Expected $true ` + -Actual ($tier1Block -match '#96201') +Assert-Eq -Label "CLOSED no-fix-yet (#96202) does NOT render in Tier 1" -Expected $false ` + -Actual ($tier1Block -match '#96202') +Assert-Eq -Label "CLOSED no-fix-yet (#96202) renders in Tier 3 (not dropped)" -Expected $true ` + -Actual ($tier3Block -match '#96202') + # (5) Marker-forgery via a TABLE cell: a Tier-1 title embedding the begin-marker between # newlines must NOT forge a second anchored marker line. $mdDataForgeTbl = @{} + $mdData From 772d01d3357c6b650f24320e3ab40568e17a00ad Mon Sep 17 00:00:00 2001 From: Shane Neuville <5375137+PureWeen@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:07:22 -0500 Subject: [PATCH 9/9] Make semantic hash tier-aware for no-fix-yet so closed rows refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tier-3 display fix (306a2962) renders closed no-fix-yet regressions in Tier 3 (OPEN ones stay in Tier 1), but Get-ReportSemanticHash hashed each regression as only issue:classification. When a no-fix-yet issue closed while another blocker held the verdict 🔴, every hashed component was unchanged, so the idempotent tracker updater skipped the edit and left a stale Tier-1 row. Fold a state-derived tier bit (t1/t3) into the hash for the no-fix-yet class ONLY — the single classification whose rendered tier depends on issue state. A no-fix-yet OPEN→CLOSED flip now refreshes the tracker, while every other classification stays state-insensitive, so unrelated state transitions (e.g. a Tier-3 in-sr-active issue closing) don't churn the hash or spam watchers — preserving the hash's deliberately conservative design. Adds two discriminating tests: no-fix-yet state flip → different hash (under a held verdict); non-no-fix-yet state flip → same hash. Mutation-verified. Offline suite 637/0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../scripts/Get-ReleaseReadiness.ps1 | 17 +++++- .../tests/Test-ReleaseReadiness.ps1 | 55 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 index d9d5ff1e9cab..ffae4a1e3e30 100644 --- a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -3000,7 +3000,22 @@ function Get-ReportSemanticHash { } else { '' } regressions = if ($Data.ContainsKey('regressions') -and $Data['regressions']) { @($Data['regressions'] | Sort-Object issue | ForEach-Object { - "$($_.issue):$($_.classification)" + # `no-fix-yet` is the ONLY classification whose rendered tier + # depends on issue state (OPEN -> Tier 1, CLOSED -> Tier 3; see + # Format-MarkdownReport's $emitTier). Fold the state-derived tier + # bit into the hash for THAT class only, so a no-fix-yet issue + # closing (which moves its row T1 -> T3) flips the hash and + # refreshes the tracker — even when another blocker keeps the + # verdict symbol unchanged. Every other classification stays + # state-insensitive, so unrelated state transitions (e.g. a + # Tier-3 in-sr-active issue closing) do NOT churn the hash or + # spam issue watchers — preserving the conservative design above. + if ($_.classification -eq 'no-fix-yet') { + $nfyTier = if ($_.state -eq 'OPEN') { 't1' } else { 't3' } + "$($_.issue):$($_.classification):$nfyTier" + } else { + "$($_.issue):$($_.classification)" + } }) -join '|' } else { '' } openSrPrs = if ($Data.ContainsKey('openSrPrs') -and $Data['openSrPrs']) { diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index d1bd406a3537..4fb81215d4d0 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -1502,6 +1502,61 @@ $dataReorder['srContents'] = @{ sourcePrs = @(35003, 35001, 35002) } # reorder $hashReorder = Get-ReportSemanticHash -Data $dataReorder -Verdict $verdictA Assert-Eq -Label "Hash invariant to source-PR order" -Expected $hashA -Actual $hashReorder +# no-fix-yet state flip → DIFFERENT hash (its rendered tier moves OPEN:Tier1 -> CLOSED:Tier3, +# so the tracker MUST refresh even when the verdict symbol is pinned by another blocker). +$dataNfyOpen = @{ + metadata = @{ srHeadSha = 'aaaaaaaa1111'; fetchedAt = '2025-01-01T00:00:00Z' } + ci = @{ overall = 'green' } + srContents = @{ sourcePrs = @(35001, 35002, 35003) } + regressions = @( + @{ issue = 35001; classification = 'in-sr-active'; state = 'OPEN' } + @{ issue = 35009; classification = 'no-fix-yet'; state = 'OPEN' } # blocker holds verdict 🔴 + ) + openSrPrs = @( @{ number = 35100 } ) +} +$dataNfyClosed = @{ + metadata = @{ srHeadSha = 'aaaaaaaa1111'; fetchedAt = '2025-01-01T00:00:00Z' } + ci = @{ overall = 'green' } + srContents = @{ sourcePrs = @(35001, 35002, 35003) } + regressions = @( + @{ issue = 35001; classification = 'in-sr-active'; state = 'OPEN' } + @{ issue = 35009; classification = 'no-fix-yet'; state = 'CLOSED' } # closed → moves to Tier 3 + ) + openSrPrs = @( @{ number = 35100 } ) +} +# Verdict symbol held constant across both (simulates a second blocker keeping the report 🔴). +$hNfyOpen = Get-ReportSemanticHash -Data $dataNfyOpen -Verdict $verdictRed +$hNfyClosed = Get-ReportSemanticHash -Data $dataNfyClosed -Verdict $verdictRed +Assert-Eq -Label "Hash changes when no-fix-yet state flips (Tier1->Tier3) under a held verdict" ` + -Expected $false -Actual ($hNfyOpen -eq $hNfyClosed) + +# Unrelated classification state flip → SAME hash (no watcher spam: in-sr-active is always Tier 3, +# so its OPEN/CLOSED transition changes nothing visible and must NOT churn the hash). +$dataInSrOpen = @{ + metadata = @{ srHeadSha = 'aaaaaaaa1111'; fetchedAt = '2025-01-01T00:00:00Z' } + ci = @{ overall = 'green' } + srContents = @{ sourcePrs = @(35001, 35002, 35003) } + regressions = @( + @{ issue = 35001; classification = 'in-sr-active'; state = 'OPEN' } + @{ issue = 35009; classification = 'no-fix-yet'; state = 'OPEN' } + ) + openSrPrs = @( @{ number = 35100 } ) +} +$dataInSrClosed = @{ + metadata = @{ srHeadSha = 'aaaaaaaa1111'; fetchedAt = '2025-01-01T00:00:00Z' } + ci = @{ overall = 'green' } + srContents = @{ sourcePrs = @(35001, 35002, 35003) } + regressions = @( + @{ issue = 35001; classification = 'in-sr-active'; state = 'CLOSED' } # only this differs + @{ issue = 35009; classification = 'no-fix-yet'; state = 'OPEN' } + ) + openSrPrs = @( @{ number = 35100 } ) +} +$hInSrOpen = Get-ReportSemanticHash -Data $dataInSrOpen -Verdict $verdictRed +$hInSrClosed = Get-ReportSemanticHash -Data $dataInSrClosed -Verdict $verdictRed +Assert-Eq -Label "Hash invariant to non-no-fix-yet state flip (in-sr-active stays Tier 3)" ` + -Expected $hInSrOpen -Actual $hInSrClosed + # Cross-process stability (regression guard for the unordered-hashtable shuffle). # .NET Core randomizes String.GetHashCode() per process, so a plain [hashtable] # would serialize its keys in a DIFFERENT order each process -> a DIFFERENT hash,