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 dfa522ee0edc..48e038d8c96e 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." -WarningAction Continue +} + # =================================================================== # BRANCH PARSING # =================================================================== @@ -1346,6 +1359,43 @@ $report = [PSCustomObject]@{ PriorityIssues = $priorityIssues KnownBuildErrorIssues = $kbeIssues CiScanIssues = $ciScanIssues + NightlyFeed = $null +} + +# 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 Resolve-NightlyDogfoodFreshness -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" + $nfBandPrefix = '^' + [regex]::Escape("$nfBand.") + + $nfFresh = Resolve-NightlyDogfoodFreshness -Feed $nfFeed -BandPrefixRegex $nfBandPrefix + if ($null -eq $nfFresh) { $nfFresh = @{ unknown = $true } } + + $nfBuildType = [string](Get-NightlyFeedProp $nfFresh 'buildType') + $nfLaneLabel = Format-NightlyFeedLaneLabel -Feed $nfFeed -FeedUrl $nfFeedUrl -BuildType $nfBuildType -BandNote "``$nfBand`` (preview.$nfIteration)" + $nfFresh['laneLabel'] = $nfLaneLabel + $nfFresh['feedUrl'] = $nfFeedUrl + $nfFresh['versionPrefix'] = $nfBandPrefix + + $report.NightlyFeed = $nfFresh + $nightlyFeedBanner = Format-NightlyFeedBanner -Freshness $nfFresh -Now ([DateTime]::UtcNow) + } catch { + # -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 + } } $md = [System.Text.StringBuilder]::new() @@ -1360,6 +1410,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..ffae4a1e3e30 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." -WarningAction Continue +} + # 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 @@ -2988,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']) { @@ -2999,6 +3026,27 @@ 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'] + # 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 { '' } } $json = $semantic | ConvertTo-Json -Depth 5 -Compress @@ -3065,6 +3113,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 @@ -3469,16 +3526,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) { @@ -3509,9 +3573,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() @@ -3590,6 +3654,70 @@ 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) + - 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) + 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"/"unknown" rather than disturbing the verdict. + #> + param([hashtable]$Data) + + if (-not $Script:NightlyFeedHelperLoaded) { return } + if (-not (Get-Command Resolve-NightlyDogfoodFreshness -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" + $bandPrefix = '^' + [regex]::Escape($band) + '-' + + $fresh = Resolve-NightlyDogfoodFreshness -Feed $feed -BandPrefixRegex $bandPrefix + if ($null -eq $fresh) { $fresh = @{ unknown = $true } } + + $buildType = [string](Get-NightlyFeedProp $fresh 'buildType') + $laneLabel = Format-NightlyFeedLaneLabel -Feed $feed -FeedUrl $feedUrl -BuildType $buildType -BandNote "``$band``" + $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 + $Data['nightlyFeedNow'] = $nfNow + $banner = Format-NightlyFeedBanner -Freshness $fresh -Now $nfNow + if ($banner) { $Data['nightlyFeedBanner'] = $banner } + } catch { + # -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 + } +} + function Invoke-Main { $excludes = $ExcludeBranches -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } $ctx = Resolve-Context -SrBranch $SrBranch -Repo $Repo -MainBranch $MainBranch ` @@ -3624,6 +3752,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..f3fd3967a440 --- /dev/null +++ b/.github/skills/release-readiness/scripts/NightlyFeed.ps1 @@ -0,0 +1,390 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# +.SYNOPSIS + Nightly-feed freshness helpers shared by the SR and Preview release-readiness engines. + +.DESCRIPTION + Headline 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-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 + 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. 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 + +# 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 { + # 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. + # -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 + } +} + +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 } } + # 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 +} + +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-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 + 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..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, @@ -1617,6 +1672,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 @@ -1831,6 +1902,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 @@ -3387,6 +3492,360 @@ 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) + +# 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) + 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[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 +} + +# (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) +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) + +# ───── 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), +# 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) + +# 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']) + +# ───── 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 })