Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/skills/release-readiness/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,19 @@ This skill has **three** PowerShell entry points and one workflow:
| Script | Branch type | Purpose |
|--------|-------------|---------|
| [`Find-ReleaseReadinessTrackers.ps1`](scripts/Find-ReleaseReadinessTrackers.ps1) | both | Detects active in-flight & candidate trackers (SR and Preview) across all active majors using a four-lane algorithm and the **tag-existence rule** ("a release is in flight unless its tag already exists"). Emits a single tracker JSON consumed by the workflow. |
| [`Get-ReleaseReadiness.ps1`](scripts/Get-ReleaseReadiness.ps1) | SR | Full readiness report for a single SR branch (in-flight or `-Candidate`). |
| [`Get-ReleaseReadiness.ps1`](scripts/Get-ReleaseReadiness.ps1) | SR | Full readiness report for a single SR branch (in-flight, `-Candidate`, or `-Shipped`). `-Shipped` is a display-only relabel — it surveys the SR branch exactly like in-flight but renders the header as `mode=shipped` for the post-ship tracker. |
| [`Get-PreviewReadiness.ps1`](scripts/Get-PreviewReadiness.ps1) | Preview | Full readiness report for a single Preview branch (in-flight or candidate via `-Mode candidate -SurveyRef net<major>.0`). |
| [`release-readiness.yml`](../../workflows/release-readiness.yml) | both | Daily cron + manual dispatch + PR validation. Runs `Find-Trackers -AllActiveMajors`, fans out a matrix job per tracker, and writes idempotent `[Release Readiness]` issues per branch. |

### Tag-existence rule (canonical signal)

The trackers detector is grounded in **tag existence as the source of truth for "shipped vs in-flight"**. A release is in-flight if and only if its expected tag has NOT been published — branch existence, commit recency, and milestone state are all secondary signals.

- SR shipped tag pattern: `<major>.0.<patch>` (e.g. `10.0.71` shipped → SR7 no longer produces a tracker)
- SR shipped tag pattern: `<major>.0.<patch>` (e.g. `10.0.71` shipped → SR7 retired, no longer produces a tracker)
- Preview shipped tag pattern: `<major>.0.0-preview.<N>.<date>[.<build>]` (e.g. `11.0.0-preview.5.26304.4` shipped → preview5 no longer produces a tracker)

**Post-ship lifecycle (`shipped` mode).** Most shipped SRs are retired the moment their tag exists. The **one exception** is the *most-recently-shipped* SR (highest shipped patch), which keeps emitting as `mode='shipped'` so its tracker issue stays useful through post-ship follow-up — adding the new build to the GitHub issue version dropdown, publishing release notes, closing out the milestone. The workflow treats `shipped` as **refresh-only**: it updates the tracker issue while it stays open, but **never (re)creates it**. Once a human closes the tracker, it stays closed and is not resurrected on the next scheduled run. This implements "keep updating until closed manually" without spamming a fresh issue after sign-off. Older shipped SRs are still retired.

## Quick Start

### One-shot daily report (matches what the workflow runs)
Expand All @@ -59,7 +61,7 @@ pwsh .github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1
# branchType: 'sr' | 'preview'
# branchName: canonical proposed branch slug (always populated)
# branchExists: true if the branch is on origin, false for candidates
# mode: 'in-flight' | 'candidate'
# mode: 'in-flight' | 'candidate' | 'shipped' (shipped = most-recently-shipped SR, refresh-only)
# surveyRef: ref to actually survey (branch itself, or net<major>.0 for candidates)
# canonicalKey: stable join key (e.g. net10-sr8, net11-preview6)
# issueTitle: title for the daily tracker issue
Expand Down Expand Up @@ -172,7 +174,7 @@ pwsh .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \
- Dispatches to `Get-ReleaseReadiness.ps1` (SR) or `Get-PreviewReadiness.ps1` (Preview) based on `branchType`.
- Looks for an open tracker issue by the canonical marker `<!-- release-readiness-tracker: <key> -->`.
- **Refresh path**: reuse the oldest open tracker issue (edit title + body); close any duplicates.
- **Create path**: open a new issue with `report` / `s/triaged` / `area-release-readiness` labels.
- **Create path**: open a new issue with `report` / `s/triaged` / `area-infrastructure` labels (each attached best-effort — a label missing from the repo is skipped with a warning rather than failing the create).
- **Activity gate**: skip new-issue creation when `recentCommitCount == 0` AND no open tracker issue exists. (Existing open issues are still refreshed.)
3. **`validate`** — PR-trigger path. Runs the test suite + smoke-runs all three scripts. **Does not create or modify issues.**

Expand Down Expand Up @@ -202,6 +204,7 @@ Each candidate fix PR is classified with confidence + evidence:
| `merged-non-main-only` | Fix merged but only to `inflight/current` (or similar), not `main` |
| `open-on-main` | Fix PR is OPEN against main, not yet merged |
| `no-fix-yet` | No fix PR cross-referenced from the regression issue |
| `closed-fix-unlinked` | Issue is CLOSED and a closing comment **explicitly names** a fix PR (fix/resolve/close language) that is MERGED and present on the release branch, but the PR↔issue link was never recorded (no closing keyword / cross-reference). A bare mention of a PR (e.g. naming the *cause* PR for context) does **not** qualify. Non-blocking; action is to add a closing reference for traceability |
| `needs-human-review` | Evidence is contradictory or weak |

## CI Status Categories
Expand Down
1 change: 1 addition & 0 deletions .github/skills/release-readiness/references/methodology.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ The skill must derive which `regressed-in-X.Y.Z` labels matter for a given SR:
| `merged-on-main-no-backport` | Fix PR state=MERGED, `onMain=true`, no backport PR to `$SrBranch` exists |
| `open-on-main` | Fix PR state=OPEN, base=main |
| `no-fix-yet` | No cross-referenced PR with high-confidence evidence found |
| `closed-fix-unlinked` | `no-fix-yet` would apply, BUT the issue is CLOSED and a closing comment **explicitly names** a fix PR (fix/resolve/close language) that is MERGED and whose commit is on `$SrBranch` (verified by SHA-ancestry OR the `(#<num>)` squash-subject token). A bare PR mention (the cause-PR blame pattern) is rejected. Surfaces a missing PR↔issue link rather than a false "no fix" alarm. Non-blocking (Tier 3) |
| `needs-human-review` | Only weak evidence; OR multiple candidate PRs with conflicting verdicts |

## CI Freshness
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@
does NOT exist on origin, the branch is in-flight — the release
notes for that exact patch haven't been published yet, so it hasn't
shipped. If the tag exists, the branch has already shipped that
patch and is skipped.
patch. Shipped SRs are normally retired, EXCEPT the most-recently-
shipped one (highest shipped patch), which is emitted as
mode='shipped' so its tracker keeps refreshing through post-ship
follow-up (adding the new build to the GitHub issue version dropdown,
release notes, milestone close-out). The workflow treats 'shipped'
as REFRESH-ONLY: it updates the tracker issue while it stays open but
never re-creates it, so once a human closes it, it stays closed.
Older shipped SRs are skipped.

Lane 2 — next SR off main
Identifies the highest SR (across in-flight branches AND shipped tags)
Expand Down Expand Up @@ -538,7 +545,7 @@ function New-Tracker {
param(
[int]$Major,
[int]$SrNumber,
[string]$Mode, # 'in-flight' or 'candidate'
[string]$Mode, # 'in-flight', 'candidate', or 'shipped'
[string]$BranchName, # nullable for candidate without branch
[string]$SurveyRef, # branch or development ref to survey
[string]$PriorSrBranch, # nullable; used as -SrBranch for -Candidate mode
Expand All @@ -561,6 +568,9 @@ function New-Tracker {
if ($Mode -eq 'candidate') {
$title = "[Release Readiness] .NET $Major SR$SrNumber — candidate from $SurveyRef"
}
elseif ($Mode -eq 'shipped') {
$title = "[Release Readiness] .NET $Major SR$SrNumber — shipped ($branchDisplay)"
}
return [pscustomobject]@{
branchType = 'sr'
srNumber = $SrNumber
Expand Down Expand Up @@ -728,7 +738,28 @@ function Invoke-DetectionForMajor {
$inflightBranchesBySr[$sr] = $branch
Write-Host " -> in-flight SR tracker: SR$sr (patch=$branchPatch, no tag $expectedTag yet, recent=$recent)" -ForegroundColor Green
} else {
Write-Host " -> SR$sr branch '$branch' patch=$branchPatch already shipped (tag $expectedTag exists)" -ForegroundColor DarkGray
# Already shipped (the stable tag exists). We normally retire the
# tracker here, BUT the most-recently-shipped SR still has post-ship
# follow-up the tracker should keep surfacing until a human signs off
# (e.g. adding the new build to the GitHub issue version dropdown,
# release notes, closing out the milestone). So keep emitting the
# HIGHEST shipped SR as mode='shipped'. The workflow consumes this as
# REFRESH-ONLY: it refreshes the tracker issue while it stays open and
# never re-creates it once a human closes it — implementing
# "update until closed manually" without resurrecting a closed tracker.
# Lower (older) shipped SRs stay retired.
if ($branchPatch -eq $highestShippedPatch) {
$recent = Get-RecentCommitCount -Ref $branch -Days $ActivityWindowDays
$tracker = New-Tracker -Major $Major -SrNumber $sr -Mode 'shipped' `
-BranchName $branch -SurveyRef $branch -PriorSrBranch $null `
-PriorShippedPatch $highestShippedPatch -PriorShippedTag $highestShippedTag `
-ExpectedPatch $branchPatch -ExpectedTag $expectedTag `
-HasRecentActivityCount $recent
$trackers.Add($tracker)
Write-Host " -> shipped SR tracker (refresh-until-closed): SR$sr (patch=$branchPatch, tag $expectedTag exists, recent=$recent)" -ForegroundColor Yellow
} else {
Write-Host " -> SR$sr branch '$branch' patch=$branchPatch already shipped (tag $expectedTag exists)" -ForegroundColor DarkGray
}
}
}

Expand Down
64 changes: 48 additions & 16 deletions .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ if ([string]::IsNullOrWhiteSpace($SurveyRef)) {
$SurveyRef = if ($Mode -eq 'candidate') { $mainBranch } else { $Branch }
}

# Human-readable label for the daily-flow merge-up CHAIN that feeds the survey
# ref. The preview lane is a two-hop chain: main → net<N>.0 → previewN. An
# in-flight preview surveys previewN, so BOTH hops feed it (main → net<N>.0 →
# previewN). A candidate's survey ref IS net<N>.0, so the chain it sees is the
# single main → net<N>.0 hop. Used for the merge-up check Area, the high-priority
# blurb, and the carve-out list (which must stay string-equal to the check Area).
$mergeUpChainLabel = if ($Mode -eq 'candidate') { "main → $SurveyRef" } else { "main → $mainBranch → $SurveyRef" }

# Canonical tracker key. Default matches Find-Trackers' New-PreviewTracker.
if ([string]::IsNullOrWhiteSpace($TrackerKey)) {
$TrackerKey = "net$majorVersion-preview$previewNumber"
Expand Down Expand Up @@ -798,20 +806,33 @@ function Get-CategorizedPullRequests {
$maestroPRs = @($allReleasePRs | Where-Object { $_.author -and $_.author.login -match "dotnet-maestro" -and ($p0PrNumbers -notcontains $_.number) })

# Non-P/0, non-Maestro humans, split by scope.
$targetHumanPRsRaw = @($TargetPRs | Where-Object { -not ($_.author -and $_.author.login -match "dotnet-maestro") -and ($p0PrNumbers -notcontains $_.number) })
$inflightHumanPRs = @($InflightPRs | Where-Object { -not ($_.author -and $_.author.login -match "dotnet-maestro") })

# 3. Merge-up: non-P/0, non-Maestro target PRs. MAUI convention:
# - head ref like `merge/main-to-net11.0` or `merge/preview4-to-net11.0`
# - title like "[automated] Merge branch 'main' => 'net11.0'"
$mergeUpPRs = @($targetHumanPRsRaw | Where-Object {
$targetHumanPRsRaw = @($TargetPRs | Where-Object { -not ($_.author -and $_.author.login -match "dotnet-maestro") -and ($p0PrNumbers -notcontains $_.number) })
$inflightHumanPRsRaw = @($InflightPRs | Where-Object { -not ($_.author -and $_.author.login -match "dotnet-maestro") })

# 3. Merge-up: non-P/0, non-Maestro PRs from BOTH hops of the daily-flow chain
# that feeds the survey ref. The preview lane chains main → net<N>.0 →
# previewN, so a stuck merge-up at EITHER hop starves the release of
# upstream fixes:
# - net<N>.0 → previewN (base = survey ref) — from target PRs
# - main → net<N>.0 (base = inflight branch) — from inflight PRs
# Both are hoisted to high priority rather than the second hop being buried
# as generic inflight-queue noise (the #36085 scenario). In candidate mode
# the inflight set is empty, so only the single base=net<N>.0 hop is seen —
# no double counting across modes.
# MAUI convention:
# - head ref like `merge/main-to-net11.0` or `merge/preview4-to-net11.0`
# - title like "[automated] Merge branch 'main' => 'net11.0'"
$allHumanPRs = @($targetHumanPRsRaw) + @($inflightHumanPRsRaw)
$mergeUpPRs = @($allHumanPRs | Where-Object {
($_.headRefName -and $_.headRefName -match '^merge/.+-to-') -or
($_.title -and $_.title -match '^\[automated\] Merge branch')
})
$mergeUpPrNumbers = @($mergeUpPRs | ForEach-Object { $_.number })

# 4. Generic-human (target) = the remainder, counted/listed once.
$targetHumanPRs = @($targetHumanPRsRaw | Where-Object { $mergeUpPrNumbers -notcontains $_.number })
# 4. Generic-human (target) and inflight-human = the remainders, with merge-up
# PRs removed from BOTH so a hoisted merge-up is never also listed below.
$targetHumanPRs = @($targetHumanPRsRaw | Where-Object { $mergeUpPrNumbers -notcontains $_.number })
$inflightHumanPRs = @($inflightHumanPRsRaw | Where-Object { $mergeUpPrNumbers -notcontains $_.number })

return [PSCustomObject]@{
P0Prs = $p0Prs
Expand Down Expand Up @@ -1217,9 +1238,9 @@ if ($p1Issues.Count -gt 0) {
}

if ($mergeUpPRs.Count -gt 0) {
$checks += New-Check -Area "Merge-up PRs (main → $SurveyRef)" -Status "BLOCKED" -Details "$($mergeUpPRs.Count) open merge-up PR(s). See 🔴 High-priority items at top. Stuck merge-up PRs block daily flow and accumulate conflicts." -NextAction "Resolve and merge each before shipping."
$checks += New-Check -Area "Merge-up PRs ($mergeUpChainLabel)" -Status "BLOCKED" -Details "$($mergeUpPRs.Count) open merge-up PR(s) in the ``$mergeUpChainLabel`` daily-flow chain. See 🔴 High-priority items at top. A stuck merge-up at any hop accumulates conflicts and starves the release of upstream fixes." -NextAction "Resolve and merge each before shipping."
} else {
$checks += New-Check -Area "Merge-up PRs (main → $SurveyRef)" -Status "READY" -Details "No open merge-up PRs from ``main`` → ``$SurveyRef``." -NextAction "Continue monitoring."
$checks += New-Check -Area "Merge-up PRs ($mergeUpChainLabel)" -Status "READY" -Details "No open merge-up PRs in the ``$mergeUpChainLabel`` daily-flow chain." -NextAction "Continue monitoring."
}

if ($kbeIssues.Count -gt 0) {
Expand Down Expand Up @@ -1423,10 +1444,10 @@ if ($nightlyFeedBanner) {
# de-prioritized) before shipping.
# 3. Maestro dependency-flow PRs — open Maestro PRs against the survey ref.
# A stuck Maestro PR blocks all upstream dependency flow into this branch.
# 4. Merge-up PRs (main → survey ref) — daily-flow sync PRs whose head ref
# 4. Merge-up PRs (upstream → survey ref) — daily-flow sync PRs whose head ref
# matches `merge/...-to-...` or title starts with "[automated] Merge branch".
# A stuck merge-up PR accumulates conflicts and starves the release branch
# of new fixes from main.
# of new fixes from upstream (main on the SR lane, net11.0 on the preview lane).
# Each item is itemized (one row per issue/PR) so the captain can see exactly
# what's outstanding without drilling into the per-category PR tables below.
$highPriorityRows = New-Object System.Collections.Generic.List[hashtable]
Expand Down Expand Up @@ -1461,8 +1482,19 @@ foreach ($pr in $maestroPRs) {
}
foreach ($pr in $mergeUpPRs) {
$action = Get-PRAction -PR $pr
# Name the specific hop from the PR's base: a survey-ref-based merge-up is the
# net<N>.0 → previewN hop; an inflight-based one (base = net<N>.0) is the
# main → net<N>.0 hop. In candidate mode the survey ref IS net<N>.0, so a
# base=net<N>.0 PR is the main → net<N>.0 hop (guarded by the candidate check).
$leg = if ($Mode -ne 'candidate' -and $pr.baseRefName -eq $SurveyRef) {
"$mainBranch → $SurveyRef"
} elseif ($pr.baseRefName -eq $mainBranch) {
"main → $mainBranch"
} else {
"merge-up"
}
[void]$highPriorityRows.Add(@{
kind = "🔀 Merge-up PR (main → $SurveyRef)"
kind = "🔀 Merge-up PR ($leg)"
link = "[#$($pr.number)]($($pr.url))"
title = $pr.title
actor = "base ``$($pr.baseRefName)``, $($action.Age)d old"
Expand All @@ -1473,7 +1505,7 @@ foreach ($pr in $mergeUpPRs) {
if ($highPriorityRows.Count -gt 0) {
[void]$md.AppendLine("## 🔴 High-priority items — $($highPriorityRows.Count) item(s)")
[void]$md.AppendLine("")
[void]$md.AppendLine("_P/0 issues, P/0 PRs, Maestro PRs, and ``main`` → ``$SurveyRef`` merge-up PRs. Resolve these before treating the release as ready._")
[void]$md.AppendLine("_P/0 issues, P/0 PRs, Maestro PRs, and merge-up PRs in the ``$mergeUpChainLabel`` daily-flow chain. Resolve these before treating the release as ready._")
[void]$md.AppendLine("")
[void]$md.AppendLine("| Kind | Item | Title | Context | Next action |")
[void]$md.AppendLine("|------|------|-------|---------|-------------|")
Expand All @@ -1493,7 +1525,7 @@ $highPriorityCheckAreas = @(
'P/0 priority blockers',
'P/0 release-branch PRs',
'Maestro PRs',
"Merge-up PRs (main → $SurveyRef)"
"Merge-up PRs ($mergeUpChainLabel)"
)
$blockingChecks = @($checks | Where-Object {
$_.Status -eq 'BLOCKED' -and -not ($highPriorityCheckAreas -contains $_.Area)
Expand Down
Loading
Loading