diff --git a/.github/agents/release-readiness-agent.agent.md b/.github/agents/release-readiness-agent.agent.md new file mode 100644 index 000000000000..a8d0210d5893 --- /dev/null +++ b/.github/agents/release-readiness-agent.agent.md @@ -0,0 +1,236 @@ +--- +name: release-readiness-agent +description: Assesses ship-readiness for a .NET MAUI release branch — Servicing Releases (`release/*-srN`) AND Previews (`release/*-previewN`). Runs the `release-readiness` skill, enriches uncertain cases with WorkIQ/MCP context, and synthesizes a Ready / Conditionally Ready / Not Ready verdict. Report-only — never mutates release refs. +--- + +# Release Readiness Agent + +## Role + +You are the human-facing **adjudicator** for ship-readiness questions on .NET MAUI release branches — both Servicing Releases (SR) and Previews. Your job is to answer **"Is `` ready to ship?"** with evidence, not vibes. + +The deterministic engine lives in the [`release-readiness` skill](../skills/release-readiness/SKILL.md) — you call it, you don't reimplement it. **Read SKILL.md once** at session start so you know the script signatures, JSON output shape, classification taxonomy, and ship-check rules. Don't restate them here. + +## Why this is an agent (and not just a skill) + +The skill runs without you — cron and CI invoke its scripts directly with no LLM in the loop. The agent layer exists for three things the skill cannot do alone: + +1. **Natural-language routing** — turning "is SR8 ready?" or "how does net11 preview6 look?" into the right script + parameters. +2. **WorkIQ / MCP enrichment** — judgment over chat history, email threads, and Maestro state that PowerShell cannot deterministically express. +3. **Persona contract** — the report-only, no-release-mutations guarantee codified below, plus context isolation so per-invocation enrichment chatter doesn't pollute the main chat. + +If a caller just needs the deterministic report (cron, PR validation, "give me the raw JSON"), they should use the skill directly. If they're asking for a synthesized verdict that may need enrichment, route through this agent. + +## 🚨 HARD RULE — REPORT ONLY. NO RELEASE-REF MUTATIONS. + +This agent **NEVER** executes release operations against dotnet/maui. You produce reports; humans execute releases. + +**You MUST NOT** (refuse with a clear explanation if asked): + +- Cut release branches (e.g. `git checkout -b release/10.0.1xx-sr8`, `release/11.0.1xx-preview7`) +- Push to `origin` on any `release/*` ref or any `netN.0` inflight ref +- Merge SR/preview branches into each other or into upstream branches +- Tag releases or create release commits +- Modify any code on a `release/*` or `netN.0` branch +- Open backport PRs or close/comment on release-related PRs on the user's behalf +- Trigger pipelines or start builds against `release/*` branches +- Run any command that writes to a release ref (no `git push`, no `git merge`, no `gh pr merge`) + +**You CAN:** + +- Read git history (`git log`, `git diff`, `git show`, `gh pr view`, `gh issue view`) +- Run the skill's scripts (`Get-ReleaseReadiness.ps1`, `Get-PreviewReadiness.ps1`, `Find-ReleaseReadinessTrackers.ps1`) +- Produce JSON / markdown reports +- Recommend exact commands for the human release captain to run +- Improve this agent or the underlying skill itself (separate feature branches + PRs are fine — that's tool development, not release operations) + +If asked to perform a release operation, respond with: **"I'm report-only — I can't [cut the branch / do the merge / etc.]. Here's the report and the recommended commands for you to run yourself,"** then surface the commands as a copy-pasteable block. Do not execute them. + +## When to Invoke + +Invoke this agent for SR questions: + +- "How does SR7 look?" / "Is SRn ready to ship?" +- "What's blocking SRn?" +- "Anything we should backport into SRn?" +- "Survey release readiness for SRn" +- "Are there regression fixes missing from SRn?" + +…and for Preview questions: + +- "How does net11 preview6 look?" / "Is preview6 ready to cut?" +- "What's blocking the next preview?" +- "Survey release readiness for `release/11.0.1xx-preview6`" +- "Are we ready to cut preview6 from net11.0?" + +…and for **portfolio / cross-release** questions where no single release is named: + +- "Give me a status on releases" / "release status overview" +- "What's the status across all active releases?" +- "What needs attention across releases?" / "What's next for MAUI releases?" +- "Which releases are in flight and what's blocking them?" + +For these, do **not** ask "which release?" — the user often doesn't know which releases exist. Enumerate the active releases yourself via the **Portfolio path (§0a)**. + +If the user wants the raw deterministic report with no judgment layer (e.g. for a script, dashboard, or programmatic consumer), point them at `/release-readiness` (the skill) instead. + +## Workflow + +### 0. Determine branch type and routing + +**If the user named a specific release** (or the current branch is a release branch), inspect it: + +- `release/.0.1xx-sr` → **SR lane** → `Get-ReleaseReadiness.ps1` (`-Candidate` if the branch doesn't exist yet) +- `release/.0.1xx-preview` → **Preview lane** → `Get-PreviewReadiness.ps1` (`-Mode candidate -SurveyRef net.0` if the preview branch doesn't exist yet) + +**If the user asked a portfolio / cross-release question** (plural "releases", "status overview", "what needs attention across releases", "what's next" — no single branch named) → **Portfolio path (§0a)**. Do NOT ask "which release?" — the whole point is they may not know which releases exist. + +**Anything else** → ask the user; do not guess. + +SR branches always cut from `main` in this repo (the script enforces this with a hard error). If the user asks you to survey `inflight/*`, `staging/*`, or `backport/*` refs as if they were releases, redirect to **Candidate mode** against the appropriate base. + +### 0a. Portfolio path (cross-release status) + +When the user wants status **across all active releases**, read the live tracker issues **first** — they're the cheapest source of truth and already carry the latest automated report plus human Release Captain Notes. Only re-run the survey scripts (slow — 60-120s each, so 3-6 min for a full portfolio) when a tracker is missing, stale, or the user explicitly asks for a fresh computation. + +1. **Find the open trackers by body marker** — NOT by title (a title search also matches the release Epic and other `[Release Readiness]`-titled issues): + + ```bash + gh issue list --repo dotnet/maui --state open \ + --search 'in:body "` and `:end -->`) — **human authority that supersedes the automated verdict.** Surface these prominently; never bury or paraphrase away an action item a human wrote there. + +3. **Judge staleness before trusting content for a ship call.** The cron refresh runs weekdays 08:30 UTC. If `updatedAt` is more than ~a day old, or commits have landed since, say so and **offer** a live re-run rather than silently presenting stale numbers. (SR bodies embed ``; an unchanged hash across runs means the last run was a no-op — not that work has stalled.) + +4. **Present a portfolio roll-up** (see step 6) — one row per active release, ordered by ship urgency (nearest cut/ship first), keeping SR and Preview visually distinct. Then offer to drill into any single release via the normal single-branch lanes below. + +### 1. Resolve the branch + +- Use the branch the user named, OR the current branch if it matches a release shape, OR ask. +- Confirm it exists: `git rev-parse --verify origin/`. +- If missing → switch to **Candidate mode** (step 1b). Do NOT silently substitute another branch. + +### 1b. Candidate mode (branch not cut yet) + +**SR candidate** — branch doesn't exist; baseline against the most recent existing SR: + +```bash +pwsh .github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 \ + -SrBranch release/10.0.1xx-sr7 -Candidate \ + -RegressionLabels regressed-in-10.0.70,regressed-in-10.0.80 \ + -OutputDir CustomAgentLogsTmp/release-readiness/sr8-candidate +``` + +The script treats `origin/main` as the SR-to-be. Report header reads "CANDIDATE for next SR (vs prior)". Frame the verdict as **pre-flight** — what would ship if cut from main today — not as final ship-readiness. + +**Preview candidate** — preview branch doesn't exist; survey the upstream `netN.0` inflight: + +```bash +pwsh .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ + -Branch release/11.0.1xx-preview7 -Mode candidate -SurveyRef net11.0 \ + -OutputDir CustomAgentLogsTmp/release-readiness/preview7-candidate \ + -OutputFormat markdown +``` + +Frame as **pre-flight** for the next preview cut. + +### 2. (SR lane only) Confirm regression label scope + +Two paths: + +- **Preferred — explicit labels.** If the user mentioned versions ("regressed in 10.0.60 and 10.0.70 only"), pass `-RegressionLabels regressed-in-10.0.60,regressed-in-10.0.70`. +- **Fallback — infer with confirmation.** If the user gave no version hints, run with `-InferRegressionLabels`, show them the inferred set, then **ASK** before the full report: *"For SR7 I'd scan `regressed-in-10.0.60,regressed-in-10.0.70` (confidence: medium). Confirm or override?"* + +Never silently accept inferred labels for the final report. + +(Preview lane skips this step — Preview readiness doesn't classify backports by regression label.) + +### 3. Run the script + +Use the routing decision from step 0. See SKILL.md for the full parameter contract. Tell the user the script is running — for large repos this is 60-120s. + +### 4. Read the JSON output + +Read the `*-readiness.json` file emitted to ``. **Use it as ground truth — do NOT re-query GitHub for things the script already answered.** + +### 5. (SR lane only) Enrich `rejected-from-sr` entries with WorkIQ + +For every regression with `classification: rejected-from-sr`, call WorkIQ to find the rejection context: + +``` +workiq.ask_work_iq: + question: "Why was PR # ([title]) closed unmerged on the SR branch? Find email threads, design decisions, or chat discussions about the backport decision." +``` + +Attach WorkIQ findings as "Why rejected:" bullets under each rejected entry. If WorkIQ returns nothing, say so explicitly — never guess. + +(Preview lane skips this step — preview reports don't have a rejected-backport tier.) + +### 5b. Resolve any `UNKNOWN` ship-check rows via MCP + +Both lanes may emit `UNKNOWN` rows when a tool isn't available in the running environment. Patch them: + +| `UNKNOWN` row | MCP tool | Patch rule | +|---|---|---| +| `BAR default-channel mapping ( → .NET SDK)` | `maestro_default_channels` with `repository: https://github.com/dotnet/maui` | Mapping present + enabled → `READY`. Missing/disabled → `BLOCKED` + surface the `darc add-default-channel` command from the script's `Next action`. | +| `BAR build for HEAD ()` | `maestro_builds` with `commit: ` and `repository: https://github.com/dotnet/maui` | ≥1 build returned → `READY` and cite buildNumber/id. Empty → `WATCH` (transient, CI still running). | +| `Milestone hygiene` (API failure) | Re-run `gh auth status` and retry — milestone checks use plain `gh api`, so UNKNOWN means gh isn't scoped right. | + +Always cite the MCP query result in your write-up (e.g. *"Verified via `maestro_default_channels`: SR8 is **not** in the mapping list — see darc command above"*). + +### 6. Present the verdict + +Lead with a 1-2 sentence overall verdict (Ready 🟢 / Conditionally Ready 🟡 / Not Ready 🔴). Then surface the script's report structure — but enriched: + +- Inline WorkIQ context for rejected backports (SR lane) +- Highlight `in-sr-reverted` entries prominently (look fixed but aren't) — SR lane +- Highlight `merged-non-main-only` entries — fixes that are "merged" but not on main +- Surface fresh ci-scan WATCH signals if the scanner just flagged something +- For preview candidates, frame as "what would ship if we cut today," not "is this ready" + +**Portfolio roll-up (cross-release path from §0a).** When answering a portfolio question, lead with a one-screen table — one row per active release — then a prioritized next-actions list: + +| Release | Lane | Mode | Verdict | Top blocker(s) | Captain-note action items | Last refreshed | +|---------|------|------|---------|----------------|---------------------------|----------------| + +Order rows by ship urgency (nearest cut/ship first). Don't flatten SR and Preview into one verdict scale — call out which lane each row is. Follow the table with a short, prioritized "what needs to be done next" list drawn from the blockers + captain-note items across all rows, then offer to drill into any single release. + +### 7. Answer follow-ups + +The user will likely ask: + +- "What about issue #X?" → look it up in `release-readiness.json.regressions[]` (SR) or `preview-readiness.json` open-PRs/open-issues sections (preview) +- "Why was the backport rejected?" (SR) → re-query WorkIQ with more context +- "Is the CI failure a flake?" → delegate to the `azdo-build-investigator` skill with the failed build IDs +- "What's the diff from the last sync?" (SR) → re-run with a different `-ExcludeBranches` + +## Common pitfalls (LLM warnings, not script-enforceable) + +> ❌ **Don't survey an `inflight/*` or `staging/*` branch as if it were a release.** Release branches in dotnet/maui always cut from `main` (SR) or `netN.0` (preview). For pre-flight, use Candidate mode. + +> ❌ **Don't trust `state: MERGED` alone.** Many PRs merge only to `inflight/current`, not `main`. The script's `onMain` field is authoritative. + +> ❌ **Don't grep source PR numbers in `git log`** to verify "is this fix in SR" — backports get new PR numbers. Use `sr-source-prs.txt`. + +> ❌ **Don't conflate similarly-titled issues across platforms.** The script filters by `regressed-in-*` label, not title — trust that. + +> ❌ **Don't ship "looks ready" without checking CI freshness.** A green build older than HEAD doesn't prove anything. The script's `isAtOrAheadOfSrHead` field tells you. + +## See Also + +- **Skill** (engine, taxonomy, script contracts, output files): `.github/skills/release-readiness/SKILL.md` +- **Methodology**: `.github/skills/release-readiness/references/methodology.md` +- **Workflow** (cron + dispatch automation): `.github/workflows/release-readiness.yml` +- **Related skills**: `azdo-build-investigator` (CI deep-dives), `find-regression-risk` (per-PR risk, different question) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 07b34e14f06d..0bb4060a9c71 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -262,6 +262,13 @@ The repository includes specialized custom agents and reusable skills for specif - **Output**: Applied changes to instruction files, skills, architecture docs, code comments - **Do NOT use for**: Analysis only without applying changes → Use `/learn-from-pr` skill instead +5. **release-readiness-agent** - Assesses ship-readiness for a .NET MAUI release branch — both **SR** (`release/*-srN`) and **Preview** (`release/*-previewN`) + - **Use when**: A release (SR or Preview) is approaching ship date and you need a synthesized verdict with WorkIQ/MCP enrichment on top of the deterministic report — **or** for a portfolio question across all active releases ("status on releases", "what needs attention across releases") where the user may not know which releases exist + - **Capabilities**: Resolves the branch (SR or Preview) from natural language, picks the right script (`Get-ReleaseReadiness.ps1` for SR, `Get-PreviewReadiness.ps1` for Preview), enriches `rejected-from-sr` candidates with WorkIQ context (SR lane), patches `UNKNOWN` ship-check rows via MCP (`maestro_default_channels`, `maestro_builds`), presents an overall verdict + - **Trigger phrases**: "is SR7 ready to ship", "release readiness for release/10.0.1xx-sr7", "survey the SR8 branch", "how does net11 preview6 look", "is preview6 ready to cut", "release readiness for release/11.0.1xx-preview6" — **plus portfolio / cross-release questions with no specific release named**: "give me a status on releases", "release status overview", "what's the status across all releases", "what needs attention across releases", "what's next for MAUI releases" + - **Output**: Verdict (Ready / Conditionally Ready / Not Ready) + per-candidate classification (SR) or per-section table (Preview) + actionable next steps + - **Do NOT use for**: Programmatic / scripted consumers that just need the raw JSON — use the `release-readiness` skill directly. Reviewing a single PR (use **pr**). Running tests manually (use **sandbox-agent**). + ### Reusable Skills Skills are modular capabilities that can be invoked directly or used by agents. Located in `.github/skills/`: @@ -337,9 +344,16 @@ Skills are modular capabilities that can be invoked directly or used by agents. - **Wraps**: `maestro-cli` skill (from `dotnet-dnceng@dotnet-arcade-skills` plugin) and maestro MCP tools - **Note**: Provides MAUI-specific guardrails on top of core Maestro/darc operations — channel naming, safety deny-list, input validation, and prompt injection defense +12. **release-readiness** (`.github/skills/release-readiness/SKILL.md`) + - **Purpose**: Deterministic ship-readiness engine for .NET MAUI release branches — both **SR** (`release/*-srN`) and **Preview** (`release/*-previewN`). Surveys CI, computes what's actually shipping, classifies open regressions, identifies port candidates and rejected backports + - **Trigger phrases**: "release readiness for SRN", "is SR7 ready to ship", "survey the SR branch", "release readiness for preview6", "how does preview6 look (deterministic)", "status across all releases" (reads the live `[Release Readiness]` tracker issues by body marker — no survey re-run needed) + - **Scripts**: `Get-ReleaseReadiness.ps1` (SR lane), `Get-PreviewReadiness.ps1` (Preview lane), `Find-ReleaseReadinessTrackers.ps1` (tracker discovery) + - **Output**: JSON + Markdown report, list of source PRs, classification of regression issues (in-sr-active, rejected-from-sr, no-fix-yet, etc.) + - **Note**: Deterministic and reproducible — no MCP, no LLM judgment. Use **this skill directly** when you need raw output for a script, dashboard, cron job, or programmatic consumer. For natural-language verdict synthesis with WorkIQ enrichment, use the **`release-readiness-agent`** instead. + #### Internal Skills (Used by Agents) -12. **try-fix** (`.github/skills/try-fix/SKILL.md`) +13. **try-fix** (`.github/skills/try-fix/SKILL.md`) - **Purpose**: Proposes ONE independent fix approach, applies it, tests, records result with failure analysis, then reverts - **Used by**: pr agent Phase 3 (Fix phase) - rarely invoked directly by users - **Behavior**: Reads prior attempts to learn from failures. Max 5 attempts per session. @@ -354,6 +368,10 @@ Skills are modular capabilities that can be invoked directly or used by agents. - User: "Test this PR" → Immediately invoke **sandbox-agent** - User: "Fix issue #67890" (no PR exists) → Suggest using `/delegate` command - User: "Write tests for issue #12345" → Immediately invoke **write-tests-agent** +- User: "Is SR7 ready to ship?" → Immediately invoke **release-readiness-agent** +- User: "How does net11 preview6 look?" → Immediately invoke **release-readiness-agent** +- User: "Give me a status on releases / what needs attention across releases?" → Immediately invoke **release-readiness-agent** (portfolio mode — it enumerates active releases by reading the `[Release Readiness]` tracker issues; don't ask "which release?") +- User: "Give me the raw release-readiness JSON for SR8" → Use the **release-readiness** skill directly (no enrichment needed) **When NOT to delegate**: - User asks "What does PR #12345 do?" → Informational query, handle yourself diff --git a/.github/skills/release-readiness/SKILL.md b/.github/skills/release-readiness/SKILL.md new file mode 100644 index 000000000000..85d0f0a9e866 --- /dev/null +++ b/.github/skills/release-readiness/SKILL.md @@ -0,0 +1,293 @@ +--- +name: release-readiness +description: Assesses ship-readiness for .NET MAUI release branches — Servicing Releases (SR) and Previews. Surveys CI pipelines, computes what's actually NEW in the branch (commits + source PRs with revert detection), and cross-references open `regressed-in-*` issues against branch contents to identify port candidates, rejected backports, and unresolved regressions. Supports both in-flight and pre-cut (candidate) modes for SR and Preview branches. +metadata: + author: dotnet-maui + version: "2.0" +compatibility: Requires `gh` CLI authenticated with `repo` + `read:org` scopes. `az` CLI is optional but recommended for internal pipeline status. Run from a checkout of `dotnet/maui`. +--- + +# Release Readiness + +This skill produces deterministic, evidence-backed answers to **"Is `` ready to ship?"** for .NET MAUI release branches — both **Servicing Releases (SR)** and **Previews**, in both **in-flight** and **candidate** (pre-cut) modes. + +## 🚨 Report-only + +This skill **reports**. It does **not** execute release operations against dotnet/maui — no branch cuts, no SR merges, no tags, no pushes to `release/*` refs. If you (the agent/user invoking this skill) are asked to perform a release operation, refuse and emit the recommended commands as a copy-pasteable block for the human release captain to run. + +## When to Use + +- "How does SR8 look?" / "Is SR8 ready to ship?" +- "What's blocking SR9 candidate?" / "What would ship if we cut SR9 today?" +- "How does net11 preview6 look?" / "Are we ready to cut preview6 from net11.0?" +- "Are there any regression fixes I should backport to SR8?" +- "What's new in SR8 since the last sync?" +- "Give me a status on all releases" / "release status overview" / "what needs attention across releases" (**portfolio** — read the open `[Release Readiness]` tracker issues first; see [Reading trackers directly](#reading-trackers-directly-ad-hoc-status) below) +- Daily release-tracking automation across all active majors + +> **For per-PR regression risk** (deletions reverting prior bug-fix lines), use [`find-regression-risk`](../find-regression-risk/SKILL.md) instead — it answers a different question. + +## Architecture + +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-PreviewReadiness.ps1`](scripts/Get-PreviewReadiness.ps1) | Preview | Full readiness report for a single Preview branch (in-flight or candidate via `-Mode candidate -SurveyRef net.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: `.0.` (e.g. `10.0.71` shipped → SR7 no longer produces a tracker) +- Preview shipped tag pattern: `.0.0-preview..[.]` (e.g. `11.0.0-preview.5.26304.4` shipped → preview5 no longer produces a tracker) + +## Quick Start + +### One-shot daily report (matches what the workflow runs) + +```bash +# Detect every active in-flight + candidate tracker across all active majors +pwsh .github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1 \ + -AllActiveMajors \ + -OutputJson trackers.json + +# Emits a JSON envelope with one tracker per active branch, each carrying: +# 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' +# surveyRef: ref to actually survey (branch itself, or net.0 for candidates) +# canonicalKey: stable join key (e.g. net10-sr8, net11-preview6) +# issueTitle: title for the daily tracker issue +# regressionLabels: list of regressed-in-* labels relevant to this branch +``` + +### SR (Servicing Release) + +```bash +# In-flight SR +pwsh .github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 \ + -SrBranch release/10.0.1xx-sr8 \ + -RegressionLabels regressed-in-10.0.70,regressed-in-10.0.80 \ + -TrackerKey net10-sr8 \ + -OutputDir CustomAgentLogsTmp/release-readiness/sr8 + +# SR candidate (no branch yet — survey main; pass the PRIOR SR as -SrBranch) +pwsh .github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 \ + -SrBranch release/10.0.1xx-sr8 \ + -Candidate \ + -RegressionLabels regressed-in-10.0.80,regressed-in-10.0.90 \ + -TrackerKey net10-sr9 \ + -OutputDir CustomAgentLogsTmp/release-readiness/sr9-candidate +``` + +### Preview + +```bash +# In-flight preview +pwsh .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ + -Branch release/11.0.1xx-preview6 \ + -Mode in-flight \ + -TrackerKey net11-preview6 \ + -OutputDir CustomAgentLogsTmp/release-readiness/preview6 + +# Preview candidate (branch not cut yet — survey net11.0 instead) +pwsh .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ + -Branch release/11.0.1xx-preview6 \ + -Mode candidate \ + -SurveyRef net11.0 \ + -TrackerKey net11-preview6 \ + -OutputDir CustomAgentLogsTmp/release-readiness/preview6-candidate +``` + +## Parameters + +### `Find-ReleaseReadinessTrackers.ps1` + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `-MajorVersion` | 0 (auto from `eng/Versions.props`) | Single major to scan. | +| `-AllActiveMajors` | off | Scan every active major (current + lower in-flight). Mutually exclusive with `-MajorVersion`. | +| `-Repo` | cwd | Path to a checkout of dotnet/maui. | +| `-ActivityWindowDays` | 7 | Recent-commit window used to compute `recentCommitCount`. | +| `-NoFetch` | off | Skip `git fetch` (faster re-runs). | +| `-OutputJson` | — | File to write the tracker envelope JSON. | +| `-MaxBranches` | 50 | Safety cap on how many SR/preview branches to enumerate per major. | + +### `Get-ReleaseReadiness.ps1` (SR) + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `-SrBranch` | Yes | — | SR branch name (e.g. `release/10.0.1xx-sr8`). In `-Candidate` mode, pass the **prior** SR — it's the exclude baseline for "what's new". | +| `-Candidate` | No | off | Pre-flight mode — survey `main` (with `-SrBranch` as the prior-SR baseline) to show what WOULD ship in the next SR. | +| `-InheritFromPriorSr` | No | off | In `-Candidate` mode, model the workflow where the prior SR is merged into the new branch after cut. Candidate's "what's shipping" set = main-since-priorSR ∪ priorSR-only commits. | +| `-RegressionLabels` | One of these | — | Comma-separated `regressed-in-*` labels. | +| `-InferRegressionLabels` | One of these | off | Auto-infer from `-SrBranch`. Agent should confirm before relying on this for automation. | +| `-Repo` | No | `dotnet/maui` | Repository in `owner/name` form. | +| `-MainBranch` | No | `main` | Stable branch used for ancestry checks. | +| `-ExcludeBranches` | No | `origin/main` | Branches to exclude from SR-only commit computation. | +| `-Phase` | No | `all` | `all`, `ci`, `commits`, `regressions`, or `open-prs`. | +| `-TrackerKey` | No | — | Canonical key (e.g. `net10-sr8`) embedded in the markdown body for idempotent issue lookup. | +| `-OutputDir` | No | — | If set, writes `release-readiness.{json,md}` and `sr-source-prs.txt`. | +| `-OutputFormat` | No | `both` | `json`, `markdown`, or `both`. | +| `-MaxIssues` | No | `100` | Cap on regression issues to walk. | +| `-NoFetch` | No | off | Skip `git fetch`. | +| `-SkipMaestroChecks` | No | off | Skip BAR/darc operational checks (default-channel mapping + per-HEAD build lookup). Auto-skipped silently if `darc` isn't on PATH; this switch forces the skip even when darc IS available. | +| `-SkipMilestoneChecks` | No | off | Skip GitHub-milestone hygiene checks (current/next milestone existence + stale-open detection). | + +### `Get-PreviewReadiness.ps1` (Preview) + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `-Branch` | Yes | — | Preview branch name (e.g. `release/11.0.1xx-preview6`). Required even for candidate runs — used to derive milestone, tracker key, and regression labels. | +| `-Mode` | No | `in-flight` | `in-flight` (survey the preview branch itself) or `candidate` (survey `-SurveyRef` instead — typically `net.0`). | +| `-SurveyRef` | No | computed | Ref to actually survey. Defaults to `$Branch` for in-flight; `net.0` for candidate. | +| `-Repository` | No | `dotnet/maui` | Repository in `owner/name` form. | +| `-TrackerKey` | No | derived | Canonical key (default: `net-preview`) embedded for idempotent issue lookup. | +| `-OutputDir` | No | — | If set, writes `preview-readiness.{json,md}`. | +| `-OutputFormat` | No | `markdown` | `markdown`, `json`, or `both`. | +| `-IncludeInternal`, `-InternalBuildId` | No | — | Release-captain only — augments report with internal pipeline status when AzDO auth is available. | +| `-PublicSafe` | No | `$true` | Sanitizes non-READY internal status from public output. | + +## Outputs + +| File | Producer | Purpose | +|------|----------|---------| +| `trackers.json` | Find-Trackers | List of active tracker descriptors with detection evidence (one envelope per major) | +| `release-readiness.{json,md}` | Get-ReleaseReadiness | Full SR readiness report | +| `sr-source-prs.txt` | Get-ReleaseReadiness | Flat newline-delimited source PR list; use `grep -qxF NNNNN file` for instant cherry-pick verification | +| `sr-commits.json` | Get-ReleaseReadiness | Raw SR-only commit metadata | +| `preview-readiness.{json,md}` | Get-PreviewReadiness | Full Preview readiness report | + +## Daily workflow + +`.github/workflows/release-readiness.yml` runs **weekdays at 08:30 UTC** plus `workflow_dispatch` + `pull_request` validation: + +1. **`detect-trackers`** — runs `Find-Trackers -AllActiveMajors`, emits a matrix of tracker descriptors. +2. **`per-tracker-report`** — matrix-expanded job per tracker: + - Dispatches to `Get-ReleaseReadiness.ps1` (SR) or `Get-PreviewReadiness.ps1` (Preview) based on `branchType`. + - Looks for an open tracker issue by the canonical marker ``. + - **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. + - **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.** + +### Reading trackers directly (ad-hoc status) + +The same tracker issues the cron job maintains double as a **human-readable, always-on status board** — you don't have to re-run a 60-120s survey to answer "what's the status across releases?". Find every active release by **body marker** (not title — a title search also matches the release Epic and other `[Release Readiness]`-titled issues): + +```bash +gh issue list --repo dotnet/maui --state open \ + --search 'in:body "` / `:end -->`), which carry decisions that override the automated report. Treat the content as fresh only up to the issue's `updatedAt` (cron refreshes weekdays 08:30 UTC); re-run the survey script for a given branch when you need live numbers. The natural-language **`release-readiness-agent`** wraps this as its Portfolio path (§0a). + +## Verdict Classification (SR & Preview) + +Each candidate fix PR is classified with confidence + evidence: + +| Verdict | Meaning | +|---------|---------| +| `in-sr-active` | Source PR is in the release branch and not subsequently reverted | +| `in-sr-reverted` | Backport landed but a later commit reverts it | +| `rejected-from-sr` | A backport PR targeting the release branch was opened and CLOSED unmerged | +| `backport-in-progress` | A backport PR targeting the release branch is OPEN | +| `merged-on-main-no-backport` | Fix merged to `main`, no backport PR exists | +| `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 | +| `needs-human-review` | Evidence is contradictory or weak | + +## CI Status Categories + +| CI verdict | Meaning | +|------------|---------| +| `green` | Latest build on the survey ref succeeded across all pipelines | +| `red-needs-review` | Latest build failed or partially succeeded — investigate failures before judging ship-readiness | +| `stale` | Latest build is older than the survey ref HEAD — must re-run before judging | +| `partial-unknown` | At least one pipeline couldn't be queried, but no queried pipeline is red or stale | +| `unknown` | No pipeline result could be classified | + +## Ship-readiness checks (`Get-ReleaseReadiness.ps1`) + +The SR readiness report rolls operational checks into a single **Blocking** summary at the top, so a release captain sees what must clear before ship without scrolling. Each check emits `READY`, `WATCH`, `BLOCKED`, `CLEANUP`, or `UNKNOWN` (`CLEANUP` = post-release housekeeping that does not block the current ship): + +| Check | When | Status meanings | +|-------|------|-----------------| +| **`Versions.props bump`** | All SR runs | `BLOCKED` if `eng/Versions.props` on `main` hasn't been bumped past the current SR cycle (next SR has nowhere to flow). | +| **`Versions.props servicing flip`** | Live-SR mode only | `BLOCKED` if the SR branch's `eng/Versions.props` is not flipped to servicing-release mode (`PreReleaseVersionLabel=servicing` + `StabilizePackageVersion=true`). Without it the branch builds prerelease packages and never ships as stable — CI stays green so nothing else catches it. | +| **`Bug template lists SR version`** | All SR runs | `CLEANUP` if `.github/ISSUE_TEMPLATE/bug-report.yml` on `main` is missing an entry for the SR being shipped (users can't file bugs against the version) — post-release housekeeping, not a ship blocker. | +| **`Main bumped to next SR cycle`** | All SR runs | `BLOCKED` if the next SR cycle's version hasn't been promoted on `main`. | +| **`BAR default-channel mapping`** | SR branches matching `release/X.Y.Zxx-srN` | `BLOCKED` if the SR branch is not wired to the `.NET SDK` channel in BAR. `UNKNOWN` if `darc` isn't on PATH (report includes the exact verification command). | +| **`BAR build for SR HEAD`** | When darc is available + SR HEAD SHA known | `READY` if BAR has a published build for the SR HEAD commit. `WATCH` (not blocking — transient) if CI hasn't published one yet. | +| **`Milestone for current cycle`** | SR + preview branches | `BLOCKED` if the current cycle's milestone (e.g. `.NET 10 SR8` or `.NET 11.0-preview6`) doesn't exist in the GitHub milestone list — fixed issues have nowhere to land. | +| **`Milestone for next cycle`** | SR + preview branches | `CLEANUP` if the next cycle's milestone isn't pre-created — open issues can't roll forward when current ships, but it doesn't block the current release. | +| **`Stale open milestones`** | SR + preview branches | `CLEANUP` if any milestones in the same major + same cycle type (SR or preview) are past their `due_on` by >7 days and still open (already-shipped releases accumulating untriaged issues). | +| **`CI Failure Scanner signals`** | All SR runs | `WATCH` if fresh ci-scan issues are filed in the last 24h. | +| **`Known Build Errors`** | All SR runs | `WATCH` if open Known Build Error issues exist that may explain background CI noise. | + +### Expected ship date + +The header line **`Expected ship date`** is rendered from `Get-ExpectedShipDate`, which reads `PatchVersion` from the survey ref's `eng/Versions.props` and applies the .NET release cadence: + +| PatchVersion | Cadence | Example | +|--------------|---------|---------| +| Multiple of 10 (`80`, `90`, `100`…) — also **previews** (patch=`0`) | 2nd Tuesday of the month | SR8 (`10.0.80`) → next 2nd Tuesday | +| Anything else (`81`, `82`, `91`…) | **ASAP** — no fixed cadence | SR8 hotfix `10.0.81` → as soon as ready | + +Surfaced in JSON as `expectedShipDate.{cadence, date, daysFromNow, formattedLong, note, patchVersion}` so downstream automation doesn't redo the math. + +### Maestro / BAR check gating + +The BAR checks shell out to `darc` (cached probe via `Get-Command darc`). When darc isn't installed (most CI environments), both checks emit `UNKNOWN` with the exact local-verification command embedded in the row's `Next action` — so the report **never silently skips** them. The release-readiness agent runs the same checks via the `maestro_*` MCP tools when the script reports `UNKNOWN`. + +## Methodology + +Three critical gotchas this skill encodes — see [references/methodology.md](references/methodology.md) for the full discussion: + +1. **Cherry-pick number swap**: SR backports get NEW PR numbers (e.g. main #35356 → SR7 #35428). Cannot naively grep source PR numbers; must walk SR-only commits and extract refs from commit bodies. + +2. **Timeline cross-references**: `closedByPullRequestsReferences` returns empty for most MAUI issues. The skill walks `gh api repos/.../issues/N/timeline` filtering on `cross-referenced` events. + +3. **Forward-flow / non-main merges**: A fix can merge into `inflight/current` only, not `main` (real example: PR #35609). The skill checks `git merge-base --is-ancestor $mergeCommit origin/main` before claiming a fix is "on main, just needs backport". + +## Shared module + +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. + +## 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. +- **WorkIQ**: NOT called from the PowerShell scripts (PowerShell can't invoke MCP tools). The agent enriches the script's JSON output with WorkIQ context where needed. + +## Anti-Patterns + +> ❌ **Don't naively grep source PR numbers** in the SR git log. The backport PR number replaces the source PR number in the merge commit subject. Use `sr-source-prs.txt` (produced by this skill) instead. + +> ❌ **Don't claim a fix is on `main` based on `pr-view --state MERGED`.** PRs can be merged into `inflight/current` only. The skill's `onMain` field is the authoritative check. + +> ❌ **Don't trust issue-title similarity.** Two issues can have nearly identical titles and refer to different platform-specific regressions (e.g. #35313 is the Android version, #35326 is the iOS/Mac/Win version with a different fix path). Always filter by the `regressed-in-*` label, not by title. + +> ❌ **Don't run with `-InferRegressionLabels` for automated workflows** without surfacing the inferred labels for confirmation. Label inference is brittle for non-standard SR cycles. + +> ❌ **Don't infer "in-flight" from branch existence alone.** The detector uses the **tag-existence rule** — a release is in-flight if and only if its expected tag has not been published. Branches can linger after their release ships (and SR branches don't exist yet for SR candidates). + +## Tests + +```powershell +pwsh -NoProfile -File .github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +``` + +The harness covers: + +- **Lane 1–4 detection** (shipped patch set, SR-from-main candidate, in-flight SR branches, preview lane) against the live `dotnet/maui` clone +- **Tracker emission** for SR2/SR3 (inactive), SR8 (active in-flight), SR9 (active candidate), and net11 preview6 (active candidate) +- **`-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. diff --git a/.github/skills/release-readiness/references/methodology.md b/.github/skills/release-readiness/references/methodology.md new file mode 100644 index 000000000000..cdd3a59a930d --- /dev/null +++ b/.github/skills/release-readiness/references/methodology.md @@ -0,0 +1,193 @@ +# Release Readiness — Methodology + +This document captures the algorithms used by `Get-ReleaseReadiness.ps1` and **the three gotchas** discovered through real SR analysis that the algorithms exist to prevent. + +## Gotcha #1: Cherry-Pick Number Swap + +### The trap + +It's tempting to "verify a fix is in SR" by grepping for the source PR number in the SR branch's git log: + +```bash +git log origin/release/10.0.1xx-sr7 --grep="35356" # ❌ WRONG +``` + +This **misses** the most common case: the fix was backported. MAUI's backport workflow produces a NEW PR (e.g. `#35428`) whose merge commit on SR has the subject: + +``` +[release/10.0.1xx-sr7] [Android] Fix CollectionView ScrollTo(0) IsGrouped (#35428) +``` + +The source PR number (#35356) only appears in the *body* of the backport PR (typically as `Backport of #35356`). + +### The fix + +Walk SR-only commits and extract ALL `#NNNN` references from BOTH subject AND body: + +```bash +git log --format='%H' origin/release/10.0.1xx-sr7 \ + ^origin/inflight/current ^origin/main +``` + +For each commit, parse: +- Subject `(#NNNN)` suffix → backport PR number +- Body `Backport of #NNNN` / `Cherry-picked from #NNNN` / `from PR #NNNN` → source PR number +- Body `Fixes #NNNN` / `Closes #NNNN` → fixed issue number +- Body `cherry picked from commit ` → original SHA on main + +The skill emits a deliberately **greedy** `sourcePrs` list that includes both backport and source PR numbers. A lookup `grep -qxF $prNum sr-source-prs.txt` then succeeds for either form. + +### Confidence ladder + +| Signal | Confidence | +|--------|-----------| +| `cherry picked from commit ` in body | **high** — git apply pedigree, traceable to source commit | +| `Backport of #NNNN` / `[release/...] ... (#NNNN)` subject + body match | **high** | +| Bare `#NNNN` mention in commit body | **medium** — may be unrelated issue ref | +| Subject contains `Revert` | **revert** — handled separately | + +## Gotcha #2: Empty `closedByPullRequestsReferences` + +### The trap + +GitHub's GraphQL `closedByPullRequestsReferences` field returns empty for most MAUI issues, even when a PR clearly "Fixes #N" in its body. The link only gets populated by a specific merge-time event flow that often doesn't fire. + +```bash +gh issue view 35313 --json closedByPullRequestsReferences # ❌ often empty +``` + +### The fix + +Use the issue *timeline* API and filter `cross-referenced` events: + +```bash +gh api repos/dotnet/maui/issues/35313/timeline --paginate \ + | jq '.[] | select(.event=="cross-referenced" + and .source.type=="issue" + and .source.issue.pull_request != null) + | {pr: .source.issue.number, + title: .source.issue.title, + state: .source.issue.state}' +``` + +### Evidence weighting + +A cross-reference alone is **insufficient** — anyone can mention an issue. Weight cross-referenced PRs by the strength of their link to the issue: + +| Evidence type | Strength | Detection | +|--------------|----------|-----------| +| `closing-keyword` | **high** | PR body or commit message contains `Fixes #N`, `Closes #N`, `Resolves #N` | +| `explicit-backport` | **high** | PR title prefixed `[release/...]` AND body mentions source PR | +| `linked-via-comment` | **medium** | Issue comment links to PR (often added by maintainer) | +| `mentions-only` | **low** | PR body mentions issue without closing keyword | + +Only `high` evidence produces an automatic classification; `medium`/`low` falls into `needs-human-review`. + +## Gotcha #3: Forward-Flow / Non-Main Merges + +### The trap + +A PR shows `state: MERGED` and a maintainer might assume the fix is on `main` and just needs a backport. But MAUI uses multiple long-lived branches: + +- `main` — current stable / shipped line +- `inflight/current` — next iteration (post-SR) +- `release/10.0.1xx-srN` — current SR + +A PR can merge into `inflight/current` ONLY, bypassing `main` entirely (real example: PR #35609 merged on 2026-06-01, base = `inflight/current`). + +```bash +gh pr view 35609 --json baseRefName,mergedAt,mergeCommit +# baseRefName: "inflight/current" ← NOT main! +``` + +### The fix + +Don't trust `state: MERGED`. Resolve the merge commit and check ancestry against `main`: + +```bash +mergeSha=$(gh pr view $pr --json mergeCommit | jq -r .mergeCommit.oid) +git merge-base --is-ancestor "$mergeSha" origin/main && echo "on main" || echo "NOT on main" +git merge-base --is-ancestor "$mergeSha" origin/inflight/current && echo "on inflight" +git merge-base --is-ancestor "$mergeSha" origin/release/10.0.1xx-sr7 && echo "on SR" +``` + +The skill records `onMain`, `onInflight`, `onSr` independently. A PR can be merged-and-on-main, merged-but-only-on-inflight, or merged-and-on-SR (via direct merge or backport). + +## Revert Detection + +A fix can land on SR and then be **reverted** later in the same SR window — e.g. PR #35744 was backported to SR7 then reverted via a `[Revert]` commit. A naive "is the PR in SR?" check would falsely report "in SR" while the user effectively ships without the fix. + +### Algorithm + +For each SR-only commit, detect revert intent: + +``` +isRevert = subject.startsWith("Revert ") + || subject.contains("[Revert]") + || body.contains("This reverts commit ") +``` + +For each revert commit, extract: +- The `revertsCommit` SHA from `This reverts commit .` +- The `revertsPr` number from an explicit `Revert PR #NNNN`, or the `(#NNNN)` **inside the quoted original title** (`Revert "Original title (#1234)" (#5678)` → `1234`, never the revert's own trailing `(#5678)`); the reverted commit's SHA subject is the authoritative override when available + +Then build a `reverts` map: `{sourcePr → revertCommit}`. A PR classified as `in-sr` becomes `in-sr-reverted` if its source PR appears as a key in `reverts`. + +### Ordering matters + +Verify the revert happened **after** the original landing on SR: + +``` +git log --topo-order origin/release/10.0.1xx-sr7 +``` + +A revert from SR's `git log` ordered before the fix would actually mean "the fix never landed." + +## Regression Label Inference + +### When `-InferRegressionLabels` is set + +The skill must derive which `regressed-in-X.Y.Z` labels matter for a given SR: + +1. List all existing labels matching `^regressed-in-(\d+)\.(\d+)\.(\d+)$` +2. Filter to the major.minor family implied by `$SrBranch` (e.g. `release/10.0.1xx-sr7` → 10.0.\*) +3. Sort descending by patch version +4. Take the top N labels whose patch < the SR's patch + - Heuristic: SR N is built from minor versions released since SR (N-1). For 10.0 family, each SR roughly covers 2 minor version bumps → take top 2 labels. +5. Emit `labelInferenceMode: inferred` + `confidence: medium` so callers know to confirm + +**Why this is brittle**: SR cycles can skip patches, repeat patches (hotfix), or be triggered by a single late-cycle regression. The agent **must** show inferred labels to the user before treating them as authoritative. + +## Classification Matrix (Full) + +| Verdict | Detection rules (in order, first match wins) | +|---------|----------------------------------------------| +| `in-sr-reverted` | Source PR's commit on SR is reverted by a later revert commit | +| `in-sr-active` | Source PR number ∈ SR `sourcePrs` AND not reverted | +| `rejected-from-sr` | A backport PR targeting `$SrBranch` exists, state=CLOSED, merged=false | +| `backport-in-progress` | A backport PR targeting `$SrBranch` exists, state=OPEN | +| `merged-non-main-only` | Fix PR state=MERGED, `onMain=false`, `onInflight=true` | +| `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 | +| `needs-human-review` | Only weak evidence; OR multiple candidate PRs with conflicting verdicts | + +## CI Freshness + +A passing CI build is only meaningful if it ran **at or after** the current SR HEAD. The skill records: + +```json +"latestBuild": { + "sourceSha": "...", + "isAtOrAheadOfSrHead": true|false, + "completedAt": "..." +} +``` + +If `isAtOrAheadOfSrHead=false`, the pipeline verdict is `stale` regardless of result. The user must re-run before judging. + +## Why no WorkIQ in the script + +WorkIQ is an MCP tool only available to the agent, not to PowerShell scripts. The script's job is to identify **which** PRs need WorkIQ context (e.g. all `rejected-from-sr` PRs); the agent enriches the JSON output by calling WorkIQ and adding `workIqContext` fields. + +This keeps the script reproducible (any user can run it deterministically) and concentrates judgment work where the LLM can apply it. diff --git a/.github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1 b/.github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1 new file mode 100644 index 000000000000..2c759778fe08 --- /dev/null +++ b/.github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1 @@ -0,0 +1,910 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# +.SYNOPSIS + Determines which .NET MAUI Release Readiness tracker issues should + exist right now based on shipped tags + current release branches. + Covers both Servicing Releases (SR) AND Previews. + +.DESCRIPTION + Deterministic auto-detection used by the daily release-readiness workflow. + Implements a four-lane algorithm documented in the release-readiness + SKILL.md: + + Lane 1 — in-flight SR branches + For every branch matching the strict regex + `^release/\.0\.\d+xx-sr(\d+)$`, read PatchVersion from its + eng/Versions.props. If the stable tag `.0.` + 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. + + Lane 2 — next SR off main + Identifies the highest SR (across in-flight branches AND shipped tags) + and proposes `SR(highest + 1)` from main IF no branch for that SR + already exists. Survey reference is the development branch for the + major (typically `main`, or `net.0` when main has rolled over). + Skipped entirely for pre-GA majors (no `.0.0` tag yet). + + Lane 3 — in-flight preview branches + For every branch matching `^release/\.0\.\d+xx-preview(\d+)$`, + check whether ANY tag matches `.0.0-preview..[.]`. + Tag absent → preview is in-flight. Tag present → already shipped, skip. + Same tag-existence rule as Lane 1, parallel semantics. + + Lane 4 — next preview off net.0 (or main if no net.0) + Reads PreReleaseVersionIteration from net.0's eng/Versions.props + (or main if main carries the preview cycle for this major). If that + iteration number has no matching tag AND no matching branch, propose + a candidate preview tracker. Skipped for majors that are in SR phase + (PreReleaseVersionLabel is not 'preview'). + + Tag existence is the authoritative ship signal (the release-notes + publish job creates the tag). This is more robust than comparing + the branch's PatchVersion against the highest known patch: + - works regardless of ship order (SR8 can ship before SR7) + - works for hotfix branches that may reset PatchVersion below + the highest known patch + - never depends on inferring "shipped" from version arithmetic + + All git failures fail-closed: the script exits non-zero and emits no + detections, never an empty success. + + For each detected tracker, computes: + - canonical key (stable issue-search marker) + - regression labels (one per shipped SR in the band, inferred) + - prior shipped patch / tag (for candidate mode -SrBranch + exclude) + - recent-activity flag (true if surveyRef had commits in the last + ActivityWindowDays days) — used by the workflow to decide whether + to create a NEW issue when no open one exists + + The workflow caller is responsible for honoring `hasRecentActivity`: + - if an open tracker issue exists -> always update + - if no open tracker exists -> only create if hasRecentActivity = true + This naturally honors human-closed inactive trackers and surfaces newly- + active abandoned SRs. + +.PARAMETER MajorVersion + Override the .NET major version. Default: auto-detected from + origin/main:eng/Versions.props. Ignored if -AllActiveMajors is set. + +.PARAMETER AllActiveMajors + Auto-detect all active major versions (main's major plus any major with + a `net.0` branch where N != main's major) and run detection for each. + Output shape changes to { majors: [ { majorVersion, ... }, ... ] }. + +.PARAMETER Repo + Path to a git checkout of dotnet/maui with origin remote. Default: current + directory. + +.PARAMETER ActivityWindowDays + Days to look back for commit activity on the surveyRef. Default: 7. + +.PARAMETER NoFetch + Skip `git fetch origin --tags`. Use cached refs. + +.PARAMETER OutputJson + Path to write the JSON result. If unset, writes to stdout. + +.PARAMETER MaxBranches + Safety cap on number of release branches inspected. Default: 50. + +.EXAMPLE + # Detect what trackers should exist today for main's major; print to stdout + pwsh ./Find-ReleaseReadinessTrackers.ps1 + +.EXAMPLE + # Run for ALL active majors (used by the daily workflow) + pwsh ./Find-ReleaseReadinessTrackers.ps1 -AllActiveMajors -OutputJson CustomAgentLogsTmp/release-readiness/all-trackers.json + +.EXAMPLE + # Run for a non-current major version (cross-major support) + pwsh ./Find-ReleaseReadinessTrackers.ps1 -MajorVersion 9 -OutputJson CustomAgentLogsTmp/release-readiness/sr-trackers.json + +.OUTPUTS + Single-major mode: + { detectedAt, repo, majorVersion, mainBranch, highestShippedPatch, + highestShippedTag, activityWindowDays, trackers: [ ... ] } + + Multi-major (-AllActiveMajors) mode: + { detectedAt, repo, activityWindowDays, majors: [ { ...same shape as single-major... }, ... ] } + + Each tracker (SR): + { branchType: 'sr', srNumber, majorVersion, mode, branchName, surveyRef, + priorSrBranch, canonicalKey, issueTitle, expectedTag, milestoneName, + regressionLabels, hasRecentActivity, recentCommitCount, + priorShippedPatch, priorShippedTag } + + Each tracker (preview): + { branchType: 'preview', previewNumber, majorVersion, mode, branchName, + surveyRef, canonicalKey, issueTitle, expectedTagPrefix, milestoneName, + regressionLabels, hasRecentActivity, recentCommitCount } +#> + +[CmdletBinding()] +param( + [int]$MajorVersion = 0, + [switch]$AllActiveMajors, + [string]$Repo = (Get-Location).Path, + [int]$ActivityWindowDays = 7, + [switch]$NoFetch, + [string]$OutputJson, + [int]$MaxBranches = 50 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +Import-Module (Join-Path $PSScriptRoot '..' '..' '..' 'scripts' 'shared' 'MauiReleaseVersioning.psm1') -Force + +# Strict regex contracts. These deliberately reject malformed/temporary refs +# so abandoned, backup, or experimental branches don't masquerade as tracks. +# SR branch: must end in `-sr` with no further qualifiers. +# rejects: sr-next, sr10-test, sr8-backup, sr10-old +$Script:StrictSrBranchRegex = '^release/(\d+)\.0\.\d+xx-sr(\d+)$' +# Preview branch: must end in `-preview` with no further qualifiers. +# rejects: preview-next, preview6.1 (sub-preview), preview7-test +$Script:StrictPreviewBranchRegex = '^release/(\d+)\.0\.\d+xx-preview(\d+)$' +# Stable tag: exactly `.0.`, no prerelease suffix. +$Script:StrictStableTagRegex = '^(\d+)\.0\.(\d+)$' +# Preview tag: `.0.0-preview..[.]` +# e.g., 11.0.0-preview.5.26304.4 +$Script:StrictPreviewTagRegex = '^(\d+)\.0\.0-preview\.(\d+)\.\d+(?:\.\d+)?$' + +# Backwards-compatible exports for tests that dot-source this script. +# Tests assert against the same regex strings the algorithm uses. +$Global:FindReleaseReadinessTrackers_StrictSrBranchRegex = $Script:StrictSrBranchRegex +$Global:FindReleaseReadinessTrackers_StrictPreviewBranchRegex = $Script:StrictPreviewBranchRegex +$Global:FindReleaseReadinessTrackers_StrictStableTagRegex = $Script:StrictStableTagRegex +$Global:FindReleaseReadinessTrackers_StrictPreviewTagRegex = $Script:StrictPreviewTagRegex + +function Invoke-GitOrFail { + <# + .SYNOPSIS + Runs git with the given arguments. Captures stdout and exits non-zero + on failure (fail-closed). Empty output is allowed; only a non-zero + exit code triggers a fail-close. + #> + param([string[]]$ArgList, [string]$FailureMessage) + $out = & git -C $Repo @ArgList 2>&1 + if ($LASTEXITCODE -ne 0) { + $joined = ($out -join "`n") + throw "Fail-closed: $FailureMessage (git exit $LASTEXITCODE)`n$joined" + } + return $out +} + +function Get-StableTagsForMajor { + <# + .SYNOPSIS + Returns the stable (non-prerelease) tags for a given major version, + in ascending patch order. Throws on git failure. + #> + param([int]$Major) + $allTags = Invoke-GitOrFail @('--no-pager', 'tag', '-l') "Could not list tags" + $tags = @($allTags | Where-Object { + $_ -and ($_ -match $Script:StrictStableTagRegex) -and ([int]$Matches[1] -eq $Major) + } | Sort-Object { + if ($_ -match $Script:StrictStableTagRegex) { [int]$Matches[2] } else { 0 } + }) + # Unary comma keeps an empty array from collapsing to $null at the call site. + ,$tags +} + +function Get-PreviewTagsForMajor { + <# + .SYNOPSIS + Returns the preview tags for a given major version, in ascending + previewNumber order. Throws on git failure. + .DESCRIPTION + Matches tags of the form `.0.0-preview..[.]`. + Tag sort is by previewNumber only (date suffixes for the same preview + are kept in lexical order, which is usually chronological since the + date prefix is YYYYMMDD). + #> + param([int]$Major) + $allTags = Invoke-GitOrFail @('--no-pager', 'tag', '-l') "Could not list tags" + $tags = @($allTags | Where-Object { + $_ -and ($_ -match $Script:StrictPreviewTagRegex) -and ([int]$Matches[1] -eq $Major) + } | Sort-Object { + if ($_ -match $Script:StrictPreviewTagRegex) { [int]$Matches[2] } else { 0 } + }, { $_ }) + ,$tags +} + +function Get-ShippedPatchSet { + <# + .SYNOPSIS + Builds a HashSet[int] of shipped patch numbers from a list of stable + tags (typically the output of Get-StableTagsForMajor). + .DESCRIPTION + O(1) lookup is essential for the in-flight loop: each branch needs + to ask "does my PatchVersion already have a published tag?". + + Malformed/prerelease/non-matching tags are silently dropped — this + function is for the in-flight check only, where only exact stable + tag matches count as "shipped". + #> + param([AllowEmptyCollection()][string[]]$StableTags) + $set = [System.Collections.Generic.HashSet[int]]::new() + if ($null -eq $StableTags) { return ,$set } + foreach ($tag in $StableTags) { + if ($tag -and ($tag -match $Script:StrictStableTagRegex)) { + [void]$set.Add([int]$Matches[2]) + } + } + # Unary comma prevents PS from unrolling the single-object return value. + ,$set +} + +function Get-ShippedPreviewSet { + <# + .SYNOPSIS + Builds a HashSet[int] of shipped preview numbers from a list of + preview tags (typically the output of Get-PreviewTagsForMajor). + .DESCRIPTION + Mirrors Get-ShippedPatchSet semantics but for preview tags. + A preview is considered shipped as soon as ANY tag matching + `.0.0-preview..*` exists. Multiple tags for the same + preview (e.g., a re-tagged final build) collapse to one entry. + #> + param([AllowEmptyCollection()][string[]]$PreviewTags) + $set = [System.Collections.Generic.HashSet[int]]::new() + if ($null -eq $PreviewTags) { return ,$set } + foreach ($tag in $PreviewTags) { + if ($tag -and ($tag -match $Script:StrictPreviewTagRegex)) { + [void]$set.Add([int]$Matches[2]) + } + } + ,$set +} + +function Test-IsBranchInFlight { + <# + .SYNOPSIS + True if the SR branch is in-flight (its expected stable tag has not + been published). False if its tag already exists (shipped). + .DESCRIPTION + The release-notes pipeline creates the tag `.0.` when + a release publishes. Tag absent → branch hasn't shipped that patch + → in-flight. Tag present → already shipped → skip. + + This replaces the older "PatchVersion > HighestShippedPatch" check, + which was fragile to out-of-order ships and hotfix branches that + reset PatchVersion. + #> + param( + [Parameter(Mandatory)][int]$BranchPatch, + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[int]]$ShippedPatches + ) + return -not $ShippedPatches.Contains($BranchPatch) +} + +function Test-IsPreviewBranchInFlight { + <# + .SYNOPSIS + True if the preview branch is in-flight (no tag matching + `.0.0-preview..*` has been published). False if any + matching tag exists (shipped). + .DESCRIPTION + Parallel to Test-IsBranchInFlight but uses the preview-tag shipped + set. As soon as the release-notes pipeline publishes ANY tag for + preview N, that preview is considered shipped. + #> + param( + [Parameter(Mandatory)][int]$PreviewNumber, + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [System.Collections.Generic.HashSet[int]]$ShippedPreviews + ) + return -not $ShippedPreviews.Contains($PreviewNumber) +} + +function Get-ActiveMajorVersions { + <# + .SYNOPSIS + Returns the list of active .NET major versions to detect trackers for. + .DESCRIPTION + Active = main's MajorVersion + any `net.0` branch on origin where + N >= main's major. This catches the common cross-major state where + main is still on major N but net(N+1).0 has forked off to start the + next major's preview cycle. + + Older `net.0` branches (N < main's major) are frozen artifacts + from previous major cycles — surveying them produces dead trackers + with no shipped tags (because they predate the modern preview-tag + scheme) and no recent activity. We exclude them. + + Returned list is sorted ascending and deduplicated. + #> + [CmdletBinding()] + param() + $majors = New-Object System.Collections.Generic.SortedSet[int] + $mainMajor = Get-CurrentMajorVersion -Repo $Repo + [void]$majors.Add($mainMajor) + + # Inspect any `net.0` branches on origin, only N >= main's major. + $lines = Invoke-GitOrFail @('ls-remote', '--heads', 'origin', 'net*.0') ` + "Could not list net*.0 branches on origin" + foreach ($line in $lines) { + if (-not $line) { continue } + if ($line -match '^[0-9a-f]{40}\s+refs/heads/net(\d+)\.0$') { + $candidate = [int]$Matches[1] + if ($candidate -ge $mainMajor) { + [void]$majors.Add($candidate) + } + } + } + # Wrap in `,` (unary comma) so an array result doesn't unroll when consumed + # by `foreach` in callers under PS 7+ (which does the right thing) AND under + # PS 5.1 (which is more eager to flatten). + ,@($majors) +} + +function Get-RemoteSrBranchesForMajor { + <# + .SYNOPSIS + Returns an array of branch names (without `refs/heads/` prefix) on + origin matching the strict SR pattern for the given major version. + Throws on git failure. + .OUTPUTS + @(@{ branch = 'release/10.0.1xx-sr7'; srNumber = 7 }, ...) + Sorted by srNumber ascending. + #> + param([int]$Major) + # Use a wide globbed ls-remote so we can validate strictly in PS. The + # globs `release/.0.*xx-sr*` still need post-filtering because + # git globs are not regex. + $lines = Invoke-GitOrFail @('ls-remote', '--heads', 'origin', "release/$Major.0.*xx-sr*") ` + "Could not list remote SR branches for major $Major" + $branches = @() + foreach ($line in $lines) { + if (-not $line) { continue } + # Format: "\trefs/heads/" + if ($line -match '^[0-9a-f]{40}\s+refs/heads/(.+)$') { + $branch = $Matches[1] + if ($branch -match $Script:StrictSrBranchRegex) { + $branchMajor = [int]$Matches[1] + $sr = [int]$Matches[2] + if ($branchMajor -eq $Major) { + $branches += [pscustomobject]@{ + branch = $branch + srNumber = $sr + } + } + } else { + Write-Verbose "Skipping non-strict SR branch '$branch' (would be ignored by lane 1)" + } + } + } + # Stable, deterministic order: srNumber ascending. + $branches = @($branches | Sort-Object srNumber) + if ($branches.Count -gt $MaxBranches) { + throw "Fail-closed: matched $($branches.Count) SR branches for major $Major (> MaxBranches=$MaxBranches). Bump -MaxBranches or investigate ghost refs." + } + # Unary comma preserves the array shape even when empty (otherwise PS unrolls @() to $null at the call site). + ,$branches +} + +function Get-RemotePreviewBranchesForMajor { + <# + .SYNOPSIS + Returns an array of branch names matching the strict preview pattern + for the given major version on origin. Throws on git failure. + .OUTPUTS + @(@{ branch = 'release/11.0.1xx-preview6'; previewNumber = 6 }, ...) + Sorted by previewNumber ascending. + #> + param([int]$Major) + $lines = Invoke-GitOrFail @('ls-remote', '--heads', 'origin', "release/$Major.0.*xx-preview*") ` + "Could not list remote preview branches for major $Major" + $branches = @() + foreach ($line in $lines) { + if (-not $line) { continue } + if ($line -match '^[0-9a-f]{40}\s+refs/heads/(.+)$') { + $branch = $Matches[1] + if ($branch -match $Script:StrictPreviewBranchRegex) { + $branchMajor = [int]$Matches[1] + $previewN = [int]$Matches[2] + if ($branchMajor -eq $Major) { + $branches += [pscustomobject]@{ + branch = $branch + previewNumber = $previewN + } + } + } else { + Write-Verbose "Skipping non-strict preview branch '$branch' (would be ignored by lane 3)" + } + } + } + $branches = @($branches | Sort-Object previewNumber) + if ($branches.Count -gt $MaxBranches) { + throw "Fail-closed: matched $($branches.Count) preview branches for major $Major (> MaxBranches=$MaxBranches). Bump -MaxBranches or investigate ghost refs." + } + # Unary comma preserves the array shape even when empty. + ,$branches +} + +function Get-RecentCommitCount { + <# + .SYNOPSIS + Counts commits on the given ref in the last $Days days. Used to gate + "create a NEW tracker issue" decisions. + #> + param([string]$Ref, [int]$Days) + $remoteRef = if ($Ref -match '^origin/') { $Ref } else { "origin/$Ref" } + # Use --pretty=format:%H to count lines without a trailing newline. + $lines = Invoke-GitOrFail @('--no-pager', 'log', $remoteRef, "--since=${Days}.days", '--pretty=format:%H') ` + "Could not count recent commits on $remoteRef" + if (-not $lines) { return 0 } + return @($lines | Where-Object { $_ }).Count +} + +function New-RegressionLabelList { + <# + .SYNOPSIS + Builds the canonical `regressed-in-X.Y.NN` label list for an SR. + .DESCRIPTION + The label set covers the prior shipped SR (`.0.`) + AND the SR's own patch band (`.0.`). + + Examples: + SR7 (patch=71) -> regressed-in-10.0.60, regressed-in-10.0.70 + SR8 candidate (priorSr=7, patch=80) -> regressed-in-10.0.70, regressed-in-10.0.80 + SR1 (patch=11) -> regressed-in-10.0.0, regressed-in-10.0.10 + + Includes the GA label (`.0.0`) when the prior SR is 0. + #> + param([int]$Major, [int]$SrNumber) + $labels = New-Object System.Collections.Generic.List[string] + $priorSr = $SrNumber - 1 + if ($priorSr -lt 0) { $priorSr = 0 } + if ($priorSr -eq 0) { + $labels.Add("regressed-in-$Major.0.0") + } else { + $labels.Add("regressed-in-$Major.0.$($priorSr * 10)") + } + $labels.Add("regressed-in-$Major.0.$($SrNumber * 10)") + return $labels +} + +function New-PreviewRegressionLabelList { + <# + .SYNOPSIS + Builds the canonical `regressed-in-X.Y.0-previewN` label list for a + preview tracker. + .DESCRIPTION + Covers the immediately prior preview AND the preview itself. + + Examples: + preview6 -> regressed-in-11.0.0-preview5, regressed-in-11.0.0-preview6 + preview1 -> regressed-in-11.0.0-preview1 + (no prior preview to compare against — first preview) + + Note: regression labels for previews are repo-conventional. If the + team doesn't apply `regressed-in-X.Y.0-previewN` style labels yet, + the workflow can still list these in the issue body so triagers know + what to add. + #> + param([int]$Major, [int]$PreviewNumber) + $labels = New-Object System.Collections.Generic.List[string] + if ($PreviewNumber -gt 1) { + $labels.Add("regressed-in-$Major.0.0-preview$($PreviewNumber - 1)") + } + $labels.Add("regressed-in-$Major.0.0-preview$PreviewNumber") + return $labels +} + +function New-Tracker { + <# + .SYNOPSIS + Constructs an SR tracker descriptor object for the workflow. + #> + param( + [int]$Major, + [int]$SrNumber, + [string]$Mode, # 'in-flight' or 'candidate' + [string]$BranchName, # nullable for candidate without branch + [string]$SurveyRef, # branch or development ref to survey + [string]$PriorSrBranch, # nullable; used as -SrBranch for -Candidate mode + [int]$PriorShippedPatch, + [string]$PriorShippedTag, + [int]$ExpectedPatch, + [string]$ExpectedTag, + [int]$HasRecentActivityCount + ) + $canonical = "net$Major-sr$SrNumber" + $milestone = ".NET $Major SR$SrNumber" + # Always advertise a canonical proposed branch name. Even when the branch + # doesn't exist yet (candidate mode), downstream tools want a stable + # `release/.0.1xx-sr` slug; whether it exists on origin is + # surfaced via the explicit branchExists flag. + $branchExists = [bool]$BranchName + $effectiveBranchName = if ($BranchName) { $BranchName } else { "release/$Major.0.1xx-sr$SrNumber" } + $branchDisplay = if ($branchExists) { $effectiveBranchName } else { "(no branch yet — from $SurveyRef)" } + $title = "[Release Readiness] .NET $Major SR$SrNumber — $branchDisplay" + if ($Mode -eq 'candidate') { + $title = "[Release Readiness] .NET $Major SR$SrNumber — candidate from $SurveyRef" + } + return [pscustomobject]@{ + branchType = 'sr' + srNumber = $SrNumber + majorVersion = $Major + mode = $Mode + branchName = $effectiveBranchName + branchExists = $branchExists + surveyRef = $SurveyRef + priorSrBranch = $PriorSrBranch + canonicalKey = $canonical + issueTitle = $title + milestoneName = $milestone + expectedPatch = $ExpectedPatch + expectedTag = $ExpectedTag + regressionLabels = (New-RegressionLabelList -Major $Major -SrNumber $SrNumber) + hasRecentActivity = ($HasRecentActivityCount -gt 0) + recentCommitCount = $HasRecentActivityCount + priorShippedPatch = $PriorShippedPatch + priorShippedTag = $PriorShippedTag + } +} + +function New-PreviewTracker { + <# + .SYNOPSIS + Constructs a preview tracker descriptor object for the workflow. + .DESCRIPTION + Preview trackers differ from SR trackers in several ways: + - branchType = 'preview' (workflow uses this to dispatch the + right report script: Get-PreviewReadiness.ps1 vs Get-ReleaseReadiness.ps1) + - expectedTagPrefix instead of expectedTag (preview tags carry a + date+build suffix that's only known at publish time, so we + advertise the prefix `.0.0-preview..`) + - No priorSrBranch (preview cadence is sequential — surveyRef is + the branch itself for in-flight or net.0 for candidate) + - regressionLabels use the preview-specific label format + #> + param( + [int]$Major, + [int]$PreviewNumber, + [string]$Mode, # 'in-flight' or 'candidate' + [string]$BranchName, # nullable for candidate without branch + [string]$SurveyRef, + [int]$HasRecentActivityCount + ) + $canonical = "net$Major-preview$PreviewNumber" + $milestone = ".NET $Major.0-preview$PreviewNumber" + # Always advertise a canonical proposed branch name even in candidate mode. + $branchExists = [bool]$BranchName + $effectiveBranchName = if ($BranchName) { $BranchName } else { "release/$Major.0.1xx-preview$PreviewNumber" } + $branchDisplay = if ($branchExists) { $effectiveBranchName } else { "(no branch yet — from $SurveyRef)" } + $title = "[Release Readiness] .NET $Major.0 preview$PreviewNumber — $branchDisplay" + if ($Mode -eq 'candidate') { + $title = "[Release Readiness] .NET $Major.0 preview$PreviewNumber — candidate from $SurveyRef" + } + $expectedTagPrefix = "$Major.0.0-preview.$PreviewNumber." + return [pscustomobject]@{ + branchType = 'preview' + previewNumber = $PreviewNumber + majorVersion = $Major + mode = $Mode + branchName = $effectiveBranchName + branchExists = $branchExists + surveyRef = $SurveyRef + canonicalKey = $canonical + issueTitle = $title + milestoneName = $milestone + expectedTagPrefix = $expectedTagPrefix + regressionLabels = (New-PreviewRegressionLabelList -Major $Major -PreviewNumber $PreviewNumber) + hasRecentActivity = ($HasRecentActivityCount -gt 0) + recentCommitCount = $HasRecentActivityCount + } +} + +function Invoke-DetectionForMajor { + <# + .SYNOPSIS + Runs the four-lane detection algorithm for a single major version. + .DESCRIPTION + Encapsulates Lanes 1-4 so the script body can call it once (single + major) or in a loop (-AllActiveMajors). Returns a pscustomobject + with the per-major envelope and a trackers array. + #> + param([Parameter(Mandatory)][int]$Major) + + $mainBranchForMajor = Get-MainBranchForVersion -Major $Major -Repo $Repo + + # ── Step 1: Inventory all shipped stable + preview tags for this major. + # All helpers below use unary-comma return + plain assignment here. DON'T + # wrap in @(...) — that combination doubles up (returns a 1-elem array + # whose only entry is the inner array). PS unrolling is the gotcha. + $stableTags = Get-StableTagsForMajor -Major $Major + $shippedPatches = Get-ShippedPatchSet -StableTags $stableTags + $previewTags = Get-PreviewTagsForMajor -Major $Major + $shippedPreviews = Get-ShippedPreviewSet -PreviewTags $previewTags + + $highestShippedPatch = 0 + $highestShippedTag = $null + if ($stableTags.Count -gt 0) { + $highestShippedTag = $stableTags[-1] + if ($highestShippedTag -match $Script:StrictStableTagRegex) { + $highestShippedPatch = [int]$Matches[2] + } + } + $highestShippedPreview = 0 + $highestShippedPreviewTag = $null + if ($previewTags.Count -gt 0) { + $highestShippedPreviewTag = $previewTags[-1] + if ($highestShippedPreviewTag -match $Script:StrictPreviewTagRegex) { + $highestShippedPreview = [int]$Matches[2] + } + } + Write-Host "[major $Major] Shipped patches: $(if ($shippedPatches.Count -gt 0) { ($shippedPatches | Sort-Object) -join ', ' } else { '(none)' })" -ForegroundColor Cyan + Write-Host "[major $Major] Shipped previews: $(if ($shippedPreviews.Count -gt 0) { ($shippedPreviews | Sort-Object) -join ', ' } else { '(none)' })" -ForegroundColor Cyan + Write-Host "[major $Major] Highest stable tag: $(if ($highestShippedTag) { $highestShippedTag } else { '(none)' })" -ForegroundColor Cyan + Write-Host "[major $Major] Highest preview tag: $(if ($highestShippedPreviewTag) { $highestShippedPreviewTag } else { '(none)' })" -ForegroundColor Cyan + + $trackers = New-Object System.Collections.Generic.List[object] + + # ── Lane 1: in-flight SR branches. + # Helper returns via unary-comma; assign directly (don't @() wrap). + $srBranches = Get-RemoteSrBranchesForMajor -Major $Major + Write-Host "[major $Major] Found $($srBranches.Count) strict release/$Major.0.*xx-sr* branches on origin" -ForegroundColor Cyan + $highestBranchSr = 0 + $inflightBranchesBySr = @{} + foreach ($entry in $srBranches) { + $branch = $entry.branch + $sr = $entry.srNumber + if ($sr -gt $highestBranchSr) { $highestBranchSr = $sr } + + Write-Verbose "Inspecting branch $branch (sr$sr)..." + $versionInfo = Get-VersionFromGitRef -GitRef "origin/$branch" -Repo $Repo + if (-not $versionInfo) { + Write-Warning "[major $Major] Could not read Versions.props from origin/$branch — skipping (fail-soft for this branch)" + continue + } + if ($versionInfo.Tag -notmatch '^(\d+)\.0\.(\d+)$') { + Write-Warning "[major $Major] Versions.props on $branch produced unexpected tag '$($versionInfo.Tag)' — skipping" + continue + } + $branchPatch = [int]$Matches[2] + $expectedTag = $versionInfo.Tag + + if (Test-IsBranchInFlight -BranchPatch $branchPatch -ShippedPatches $shippedPatches) { + $recent = Get-RecentCommitCount -Ref $branch -Days $ActivityWindowDays + $tracker = New-Tracker -Major $Major -SrNumber $sr -Mode 'in-flight' ` + -BranchName $branch -SurveyRef $branch -PriorSrBranch $null ` + -PriorShippedPatch $highestShippedPatch -PriorShippedTag $highestShippedTag ` + -ExpectedPatch $branchPatch -ExpectedTag $expectedTag ` + -HasRecentActivityCount $recent + $trackers.Add($tracker) + $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 + } + } + + # ── Lane 2: propose next SR off main (or net.0). Skip for pre-GA + # majors: if `.0.0` hasn't shipped, this major is still in preview + # phase and there is no SR cycle yet. + $isPreGa = -not $shippedPatches.Contains(0) + if ($isPreGa) { + Write-Host "[major $Major] Pre-GA (no tag $Major.0.0) — skipping Lane 2 (no SR candidate proposed)" -ForegroundColor DarkGray + } else { + $highestShippedSr = [int]([math]::Floor($highestShippedPatch / 10)) + $highestSr = [int][math]::Max([int]$highestBranchSr, [int]$highestShippedSr) + $nextSr = $highestSr + 1 + $nextSrBranchExists = $srBranches | Where-Object { $_.srNumber -eq $nextSr } + + if (-not $nextSrBranchExists) { + $candidateRef = $mainBranchForMajor + $candidateVersionInfo = Get-VersionFromGitRef -GitRef "origin/$candidateRef" -Repo $Repo + $expectedPatch = $nextSr * 10 + $expectedTag = "$Major.0.$expectedPatch" + if ($candidateVersionInfo -and $candidateVersionInfo.Tag -match '^(\d+)\.0\.(\d+)$') { + # Only adopt main's PatchVersion if it has actually advanced to or past the + # next SR's expected band. Immediately after an SR cut, main still carries + # the cut SR's patch (e.g., main=80 right after SR8 is cut), so a naive + # adoption would mis-label the candidate as the same SR. + $mainPatch = [int]$Matches[2] + if ($mainPatch -ge $expectedPatch) { + $expectedPatch = $mainPatch + $expectedTag = $candidateVersionInfo.Tag + } + } + $recent = Get-RecentCommitCount -Ref $candidateRef -Days $ActivityWindowDays + # priorSrBranch: prefer the immediate prior SR's branch (sr) since + # the candidate by definition follows it. Falling back to "highest in-flight" + # can pick stale forgotten branches (e.g. an old sr2/sr3 left around) — those + # are NOT the prior of a current candidate. + $priorSrNumber = $nextSr - 1 + $priorSrBranchName = "release/$Major.0.1xx-sr$priorSrNumber" + $priorSrBranchExists = $srBranches | Where-Object { $_.branch -eq $priorSrBranchName } + $priorSrBranch = $null + if ($priorSrBranchExists) { + $priorSrBranch = $priorSrBranchName + } elseif ($inflightBranchesBySr.Count -gt 0) { + $inflightPrior = ($inflightBranchesBySr.Keys | Where-Object { $_ -lt $nextSr } | Sort-Object | Select-Object -Last 1) + if ($inflightPrior) { $priorSrBranch = $inflightBranchesBySr[$inflightPrior] } + } + if (-not $priorSrBranch -and $highestShippedSr -ge 1) { + $priorSrBranch = "release/$Major.0.1xx-sr$highestShippedSr" + } + $tracker = New-Tracker -Major $Major -SrNumber $nextSr -Mode 'candidate' ` + -BranchName $null -SurveyRef $candidateRef -PriorSrBranch $priorSrBranch ` + -PriorShippedPatch $highestShippedPatch -PriorShippedTag $highestShippedTag ` + -ExpectedPatch $expectedPatch -ExpectedTag $expectedTag ` + -HasRecentActivityCount $recent + $trackers.Add($tracker) + Write-Host " -> candidate SR tracker: SR$nextSr (surveyRef=$candidateRef, recent=$recent)" -ForegroundColor Green + } else { + Write-Host " -> SR$nextSr already has a branch; covered by Lane 1" -ForegroundColor DarkGray + } + } + + # ── Lane 3: in-flight preview branches. + $previewBranches = Get-RemotePreviewBranchesForMajor -Major $Major + Write-Host "[major $Major] Found $($previewBranches.Count) strict release/$Major.0.*xx-preview* branches on origin" -ForegroundColor Cyan + $highestBranchPreview = 0 + $inflightPreviewsByNum = @{} + foreach ($entry in $previewBranches) { + $branch = $entry.branch + $previewN = $entry.previewNumber + if ($previewN -gt $highestBranchPreview) { $highestBranchPreview = $previewN } + + if (Test-IsPreviewBranchInFlight -PreviewNumber $previewN -ShippedPreviews $shippedPreviews) { + $recent = Get-RecentCommitCount -Ref $branch -Days $ActivityWindowDays + $tracker = New-PreviewTracker -Major $Major -PreviewNumber $previewN -Mode 'in-flight' ` + -BranchName $branch -SurveyRef $branch ` + -HasRecentActivityCount $recent + $trackers.Add($tracker) + $inflightPreviewsByNum[$previewN] = $branch + Write-Host " -> in-flight preview tracker: preview$previewN (no $Major.0.0-preview.$previewN.* tag yet, recent=$recent)" -ForegroundColor Green + } else { + Write-Host " -> preview$previewN branch '$branch' already shipped (tag $Major.0.0-preview.$previewN.* exists)" -ForegroundColor DarkGray + } + } + + # ── Lane 4: propose next preview off net.0 (or main when main owns + # the preview cycle). Reads PreReleaseVersionIteration from the survey ref; + # if it's labeled 'preview' AND that iteration has no tag AND no matching + # branch, emit a candidate preview tracker. + # + # Survey-ref selection: + # - Prefer net.0 when it exists (it owns the preview cycle in + # cross-major state, e.g., net11.0 hosts the .NET 11 preview cycle + # while main is still on .NET 10's SR cycle). + # - Fall back to main only when net.0 doesn't exist AND main is + # for this major. Main is typically the SR development line + # (label=ci.main, not preview), so the lookup will no-op when the + # major is in SR phase. + $previewCandidateRef = $null + $candidatePreviewVersionInfo = $null + $netMajorBranch = "net$Major.0" + $netMajorExists = $false + try { + $netMajorCheck = Invoke-GitOrFail @('ls-remote', '--heads', 'origin', $netMajorBranch) ` + "Could not check existence of $netMajorBranch" + $netMajorExists = [bool]($netMajorCheck | Where-Object { $_ -and $_ -match '^[0-9a-f]{40}\s+refs/heads/' }) + } catch { + Write-Warning "[major $Major] ls-remote for $netMajorBranch failed; falling back to main for Lane 4" + } + + if ($netMajorExists) { + $previewCandidateRef = $netMajorBranch + $candidatePreviewVersionInfo = Get-VersionFromGitRef -GitRef "origin/$netMajorBranch" -Repo $Repo + } elseif ($mainBranchForMajor -eq 'main') { + $previewCandidateRef = 'main' + $candidatePreviewVersionInfo = Get-VersionFromGitRef -GitRef "origin/main" -Repo $Repo + } + + if ($candidatePreviewVersionInfo -and $candidatePreviewVersionInfo.PreLabel -eq 'preview' -and $candidatePreviewVersionInfo.PreIter -gt 0) { + $candidatePreviewN = [int]$candidatePreviewVersionInfo.PreIter + $previewBranchAlreadyExists = $previewBranches | Where-Object { $_.previewNumber -eq $candidatePreviewN } + $previewAlreadyShipped = $shippedPreviews.Contains($candidatePreviewN) + + if ($previewAlreadyShipped) { + Write-Host "[major $Major] preview$candidatePreviewN (from $previewCandidateRef) already shipped — skipping Lane 4" -ForegroundColor DarkGray + } elseif ($previewBranchAlreadyExists) { + Write-Host "[major $Major] preview$candidatePreviewN already has a branch; covered by Lane 3" -ForegroundColor DarkGray + } else { + $recent = Get-RecentCommitCount -Ref $previewCandidateRef -Days $ActivityWindowDays + $tracker = New-PreviewTracker -Major $Major -PreviewNumber $candidatePreviewN -Mode 'candidate' ` + -BranchName $null -SurveyRef $previewCandidateRef ` + -HasRecentActivityCount $recent + $trackers.Add($tracker) + Write-Host " -> candidate preview tracker: preview$candidatePreviewN (surveyRef=$previewCandidateRef, recent=$recent)" -ForegroundColor Green + } + } else { + $labelDisplay = if ($candidatePreviewVersionInfo) { ($candidatePreviewVersionInfo.PreLabel) } else { '' } + $iterDisplay = if ($candidatePreviewVersionInfo) { ($candidatePreviewVersionInfo.PreIter) } else { '' } + $refDisplay = if ($previewCandidateRef) { $previewCandidateRef } else { '' } + Write-Host "[major $Major] No active preview cycle (surveyRef=$refDisplay, label=$labelDisplay, iter=$iterDisplay)" -ForegroundColor DarkGray + } + + return [pscustomobject]@{ + majorVersion = $Major + mainBranch = $mainBranchForMajor + highestShippedPatch = $highestShippedPatch + highestShippedTag = $highestShippedTag + highestShippedPreview = $highestShippedPreview + highestShippedPreviewTag = $highestShippedPreviewTag + trackers = $trackers.ToArray() + } +} + +# ── Main ───────────────────────────────────────────────────────────────── + +# Guard: skip the driver when dot-sourced (tests dot-source to access helpers +# like New-RegressionLabelList and the strict regex constants). +if ($MyInvocation.InvocationName -eq '.' -or $MyInvocation.Line -match '^\.\s') { return } + +if (-not (Test-Path (Join-Path $Repo '.git'))) { + throw "Fail-closed: $Repo is not a git repository. Pass -Repo ." +} + +if (-not $NoFetch) { + Write-Host "Fetching origin (branches + tags)..." -ForegroundColor Cyan + Invoke-GitOrFail @('fetch', 'origin', '--tags', '--prune', '--quiet') ` + "git fetch failed (fail-closed; cannot guess in-flight SRs from stale refs)" | Out-Null +} + +if ($AllActiveMajors) { + $activeMajors = Get-ActiveMajorVersions + Write-Host "Active major versions: $($activeMajors -join ', ')" -ForegroundColor Cyan + $perMajor = New-Object System.Collections.Generic.List[object] + foreach ($m in $activeMajors) { + $perMajor.Add( (Invoke-DetectionForMajor -Major $m) ) + } + $result = [pscustomobject]@{ + detectedAt = (Get-Date).ToUniversalTime().ToString('o') + repo = (Resolve-Path $Repo).Path + activityWindowDays = $ActivityWindowDays + majors = $perMajor.ToArray() + } +} else { + # Single-major mode (back-compat with prior callers and the test E2E). + if ($MajorVersion -le 0) { + $MajorVersion = Get-CurrentMajorVersion -Repo $Repo + Write-Host "Detected MajorVersion=$MajorVersion from origin/main:eng/Versions.props" -ForegroundColor Cyan + } + $single = Invoke-DetectionForMajor -Major $MajorVersion + $result = [pscustomobject]@{ + detectedAt = (Get-Date).ToUniversalTime().ToString('o') + repo = (Resolve-Path $Repo).Path + majorVersion = $single.majorVersion + mainBranch = $single.mainBranch + highestShippedPatch = $single.highestShippedPatch + highestShippedTag = $single.highestShippedTag + highestShippedPreview = $single.highestShippedPreview + highestShippedPreviewTag = $single.highestShippedPreviewTag + activityWindowDays = $ActivityWindowDays + trackers = $single.trackers + } +} + +# ── Output ─────────────────────────────────────────────────────────────── + +$json = $result | ConvertTo-Json -Depth 8 + +if ($OutputJson) { + $dir = Split-Path -Parent $OutputJson + if ($dir -and -not (Test-Path $dir)) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + Set-Content -Path $OutputJson -Value $json -Encoding utf8 + # Resilient tracker count — single-major mode has .trackers at the root, + # AllActiveMajors mode aggregates .majors[].trackers. Total either way. + $totalTrackers = 0 + if ($result.PSObject.Properties['trackers']) { + $totalTrackers = @($result.trackers).Count + } elseif ($result.PSObject.Properties['majors']) { + $totalTrackers = ($result.majors | ForEach-Object { @($_.trackers).Count } | Measure-Object -Sum).Sum + } + Write-Host "Wrote $totalTrackers tracker(s) to $OutputJson" -ForegroundColor Cyan +} else { + Write-Output $json +} diff --git a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 new file mode 100644 index 000000000000..3a2861692297 --- /dev/null +++ b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 @@ -0,0 +1,1513 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# +.SYNOPSIS + Generates a public-safe .NET MAUI preview release-readiness report + for a specific net.0-previewN branch. + +.DESCRIPTION + This is the "preview lane" companion to Get-ReleaseReadiness.ps1 (SR lane). + + Given a preview branch (e.g. `release/11.0.1xx-preview6`), checks the + public release-readiness signals that don't require internal access: + - Target branch exists with the right PreReleaseVersionIteration + - net.0 inflight branch is bumped for the NEXT preview train + - Maestro / dependency-flow PRs + - Release-branch human PRs + - net.0 inflight PRs (preview-next watch) + - Priority release blockers (p/0, p/1) tagged release-relevant + - Known Build Error issues tagged release-relevant + - Xcode requirement variables (from eng/pipelines/common/variables.yml) + - CI truth (placeholder — not wired to #35052 yet) + - Internal release pipelines (READY/UNKNOWN classification — sanitized) + + Deterministic by design — does NOT approve, merge, rerun, promote, or + mutate GitHub / Maestro / darc state. + + Output: + - Markdown report fenced by parameterized tracker markers + + - JSON dump of the checks + collected PRs/issues when -OutputDir + is supplied + +.PARAMETER Branch + Required. Preview branch name in the form: + release/.0.1xx-preview + e.g. release/11.0.1xx-preview6 + +.PARAMETER Mode + 'in-flight' (default) — the branch already exists, survey it directly. + 'candidate' — the branch hasn't been cut yet, survey `-SurveyRef` (the + source branch the preview will be cut from) and treat the missing + target branch as informational, not blocking. + +.PARAMETER SurveyRef + Branch to survey for PRs / version checks. Defaults to `-Branch`. + For 'candidate' mode, the workflow should pass net.0 (the + upstream inflight branch the preview will be cut from). + +.PARAMETER Repository + GitHub repo to query (default dotnet/maui). + +.PARAMETER OutputDir + If supplied, writes preview-readiness.{json,md} into this directory. + If omitted, the markdown body is written to stdout. + +.PARAMETER TrackerKey + Canonical tracker slug (e.g. "net11-preview6"). Embedded in the + `` marker so the + workflow can idempotently match and update a single tracker issue. + If omitted, derived from the parsed branch (net-preview). + +.PARAMETER OutputFormat + "markdown" (default, also written when OutputDir is set), "json" + (stdout only), or "both" (write both files when OutputDir is set; the + markdown body is also returned to stdout). + +.PARAMETER IncludeInternal + When set, attempts to query internal dnceng Azure DevOps via `az` CLI + for the supplied -InternalBuildId. Only relevant for local runs by + release captains with internal access. + +.PARAMETER InternalBuildId + Internal AzDO build ID used when -IncludeInternal is set. + +.PARAMETER PublicSafe + When true (default), any non-READY internal status is sanitized to + omit raw error/log payloads before being included in the report. + +.NOTES + Faithfully ports the logic from the prior + `.github/skills/net11-release-readiness/scripts/Get-Net11ReleaseReadiness.ps1` + script (PR #35754) into the unified release-readiness skill, dropping + the `Resolve-Target` indirection in favour of explicit `-Branch` input + from the Find-ReleaseReadinessTrackers driver. +#> + +param( + [Parameter(Mandatory = $true)] + [string]$Branch, + + [Parameter(Mandatory = $false)] + [ValidateSet("in-flight", "candidate")] + [string]$Mode = "in-flight", + + [Parameter(Mandatory = $false)] + [string]$SurveyRef, + + [Parameter(Mandatory = $false)] + [string]$Repository = "dotnet/maui", + + [Parameter(Mandatory = $false)] + [string]$OutputDir, + + [Parameter(Mandatory = $false)] + [string]$TrackerKey, + + [Parameter(Mandatory = $false)] + [ValidateSet("markdown", "json", "both")] + [string]$OutputFormat = "markdown", + + [Parameter(Mandatory = $false)] + [switch]$IncludeInternal, + + [Parameter(Mandatory = $false)] + [string]$InternalBuildId, + + [Parameter(Mandatory = $false)] + [bool]$PublicSafe = $true, + + [Parameter(Mandatory = $false)] + [int]$MaxBodyBytes = 60000 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +# =================================================================== +# BRANCH PARSING +# =================================================================== +# Preview branch contract: release/.0.1xx-preview +# (Find-Trackers emits exactly this format for branchType='preview'.) +if ($Branch -notmatch '^release/(\d+)\.0\.1xx-preview(\d+)$') { + throw "Branch '$Branch' does not match expected preview format 'release/.0.1xx-preview'." +} +$majorVersion = [int]$Matches[1] +$previewNumber = [int]$Matches[2] +$mainBranch = "net$majorVersion.0" + +# In candidate mode, the preview branch hasn't been cut yet — survey the +# source instead (caller passes net.0 via -SurveyRef). In in-flight +# mode, the source IS the branch itself. +if ([string]::IsNullOrWhiteSpace($SurveyRef)) { + $SurveyRef = if ($Mode -eq 'candidate') { $mainBranch } else { $Branch } +} + +# Canonical tracker key. Default matches Find-Trackers' New-PreviewTracker. +if ([string]::IsNullOrWhiteSpace($TrackerKey)) { + $TrackerKey = "net$majorVersion-preview$previewNumber" +} + +# =================================================================== +# STATUS RANKING (worst-wins) +# =================================================================== +$StatusRank = @{ + "READY" = 0 + "CLEANUP" = 1 + "WATCH" = 1 + "UNKNOWN" = 2 + "INSUFFICIENT_DATA" = 2 + "BLOCKED" = 3 +} + +# =================================================================== +# HELPERS +# =================================================================== + +function Invoke-GitHubWithRetry { + <# + .SYNOPSIS + Calls `gh` with bounded exponential backoff on transient errors. + .DESCRIPTION + Retries on 502/503/504/timeout/stream-error/CANCEL/Bad-Gateway up + to MaxRetries (default 3) with 2^N * 2-second backoff. + Throws on persistent failure — caller must wrap if soft-fail is + wanted. + #> + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description, + [Parameter(Mandatory = $false)][int]$MaxRetries = 3 + ) + + $retryCount = 0 + $baseDelay = 2 + + while ($retryCount -lt $MaxRetries) { + $global:LASTEXITCODE = 0 + $output = & gh @Arguments 2>&1 + $exitCode = $LASTEXITCODE + $text = $output -join "`n" + + if ($exitCode -eq 0) { + return $text + } + + $retryCount++ + if ($text -match "502|503|504|timeout|stream error|CANCEL|Bad Gateway" -and $retryCount -lt $MaxRetries) { + Start-Sleep -Seconds ($baseDelay * [Math]::Pow(2, $retryCount - 1)) + continue + } + + throw "Failed to $Description" + } + + throw "Failed to $Description after $MaxRetries attempts" +} + +function ConvertFrom-JsonOrEmptyArray { + param([string]$Json) + if ([string]::IsNullOrWhiteSpace($Json)) { + return @() + } + $parsed = $Json | ConvertFrom-Json + if ($null -eq $parsed) { + return @() + } + return @($parsed) +} + +function Get-ContentFromRepo { + <# + .SYNOPSIS + Reads a file from the repo at a specific ref via gh api. + #> + param( + [string]$Path, + [string]$Ref + ) + + $encodedRef = [System.Uri]::EscapeDataString($Ref) + $json = Invoke-GitHubWithRetry -Arguments @( + "api", + "repos/$Repository/contents/$Path`?ref=$encodedRef" + ) -Description "fetch $Path from $Ref" + + $content = $json | ConvertFrom-Json + if (-not $content.content) { + throw "Content response for $Path at $Ref did not include content" + } + + $bytes = [Convert]::FromBase64String(($content.content -replace "\s", "")) + return [Text.Encoding]::UTF8.GetString($bytes) +} + +function Test-BranchExists { + param([string]$BranchName) + + $encodedBranch = [System.Uri]::EscapeDataString($BranchName) + $global:LASTEXITCODE = 0 + $output = & gh api "repos/$Repository/branches/$encodedBranch" --jq ".name" 2>&1 + $exitCode = $LASTEXITCODE + $text = $output -join "`n" + + if ($exitCode -eq 0) { + return $true + } + + if ($text -match '"status"\s*:\s*"404"|"message"\s*:\s*"Branch not found"|HTTP 404') { + return $false + } + + throw "Failed to check branch $BranchName" +} + +function Get-PreReleaseVersionIteration { + <# + .SYNOPSIS + Reads from eng/Versions.props at $Branch. + .NOTES + Returns the raw string (or $null if empty/missing). Cross-checked + as `[string] -eq` against the expected preview number, so do not + normalise to [int] here. + #> + param([string]$BranchName) + + $versions = Get-ContentFromRepo -Path "eng/Versions.props" -Ref $BranchName + if ($versions -match "\s*([^<]*)\s*") { + $value = $Matches[1].Trim() + if ([string]::IsNullOrWhiteSpace($value)) { + return $null + } + return $value + } + + return $null +} + +function Get-XcodeRequirements { + <# + .SYNOPSIS + Reads REQUIRED_XCODE and DEVICETESTS_REQUIRED_XCODE from + eng/pipelines/common/variables.yml at $Branch. + #> + param([string]$BranchName) + + $variables = Get-ContentFromRepo -Path "eng/pipelines/common/variables.yml" -Ref $BranchName + $required = $null + $deviceRequired = $null + $currentName = $null + + foreach ($line in ($variables -split "`n")) { + if ($line -match "^\s*-\s+name:\s+(.+?)\s*$") { + $currentName = $Matches[1].Trim() + continue + } + + if ($line -match "^\s*REQUIRED_XCODE\s*:\s+(.+?)\s*$") { + $required = $Matches[1].Trim().Trim("'").Trim('"') + continue + } + + if ($line -match "^\s*DEVICETESTS_REQUIRED_XCODE\s*:\s+(.+?)\s*$") { + $deviceRequired = $Matches[1].Trim().Trim("'").Trim('"') + continue + } + + if ($line -match "^\s*value:\s+(.+?)\s*$") { + $value = $Matches[1].Trim().Trim("'").Trim('"') + if ($currentName -eq "REQUIRED_XCODE") { + $required = $value + } elseif ($currentName -eq "DEVICETESTS_REQUIRED_XCODE") { + $deviceRequired = $value + } + } + } + + return [PSCustomObject]@{ + RequiredXcode = $required + DeviceTestsRequiredXcode = $deviceRequired + } +} + +function Get-BugTemplateVersions { + <# + .SYNOPSIS + Reads the `version-with-bug` dropdown options from .github/ISSUE_TEMPLATE/bug-report.yml at $Branch. + .DESCRIPTION + Returns an array of dropdown option strings (without leading `- ` markers). + Used to verify the bug template has been updated to include the version + we're about to ship — releasing a version that's missing from the template + means users can't file bug reports against it (they'd have to pick + "Unknown/Other"). Returns @() if the file is missing or the dropdown isn't found. + .NOTES + The file is a GitHub issue-form YAML. The relevant block looks like: + - type: dropdown + id: version-with-bug + attributes: + label: Version with bug + options: + - 11.0.0-preview.4 + - 10.0.70 + ... + We do a lightweight scan rather than parsing YAML to keep the dependency surface small. + #> + param([string]$BranchName) + + try { + $yaml = Get-ContentFromRepo -Path ".github/ISSUE_TEMPLATE/bug-report.yml" -Ref $BranchName + } catch { + return @() + } + if ([string]::IsNullOrWhiteSpace($yaml)) { return @() } + + $lines = $yaml -split "`n" + $inVersionDropdown = $false + $inOptions = $false + $optionsIndent = -1 + $values = New-Object System.Collections.Generic.List[string] + + foreach ($rawLine in $lines) { + $line = $rawLine.TrimEnd("`r") + + # Detect entry into the `version-with-bug` dropdown's options block. + if (-not $inVersionDropdown) { + if ($line -match '^\s*id:\s*version-with-bug\s*$') { + $inVersionDropdown = $true + } + continue + } + + # Once inside the dropdown, look for `options:` and capture its child indent. + if (-not $inOptions) { + if ($line -match '^(\s*)options:\s*$') { + $inOptions = $true + $optionsIndent = $Matches[1].Length + } + # Bail out if we hit the next top-level block before finding options. + if ($line -match '^\s*-\s*type:\s*') { break } + continue + } + + # We're inside the options list. Capture `- value` rows. + if ($line -match '^(\s*)-\s+(.+?)\s*$') { + $indent = $Matches[1].Length + if ($indent -gt $optionsIndent) { + $value = $Matches[2].Trim() + # Strip surrounding quotes if any + $value = $value.Trim("'").Trim('"') + if (-not [string]::IsNullOrWhiteSpace($value)) { + [void]$values.Add($value) + } + continue + } + } + + # Empty or differently-indented line ends the options block. + if ($line -match '^\s*$') { continue } + if ($line -match '^(\s*)\S' -and $Matches[1].Length -le $optionsIndent) { + break + } + } + + return @($values) +} + +function Get-OpenPullRequests { + param([string]$BaseBranch) + + if (-not (Test-BranchExists -BranchName $BaseBranch)) { + return @() + } + + $json = Invoke-GitHubWithRetry -Arguments @( + "pr", + "list", + "--repo", + $Repository, + "--state", + "open", + "--base", + $BaseBranch, + "--limit", + "100", + "--json", + "number,title,author,url,createdAt,updatedAt,isDraft,reviewDecision,mergeStateStatus,labels,headRefName,baseRefName" + ) -Description "list open PRs for $BaseBranch" + + return ConvertFrom-JsonOrEmptyArray $json +} + +function Get-IssuesByLabel { + param( + [string]$Label, + [switch]$IncludeBody + ) + + $fields = "number,title,url,labels,milestone,createdAt,updatedAt" + if ($IncludeBody) { $fields += ",body" } + + $json = Invoke-GitHubWithRetry -Arguments @( + "issue", + "list", + "--repo", + $Repository, + "--state", + "open", + "--limit", + "100", + "--label", + $Label, + "--json", + $fields + ) -Description "list issues with label '$Label'" + + return ConvertFrom-JsonOrEmptyArray $json +} + +function Get-CiScanLabelForBranch { + <# + .SYNOPSIS + Maps a branch/ref name to the single `ci-scan*` label its scanner + workflow writes. Returns $null when no scanner runs against the ref. + .DESCRIPTION + The CI Failure Scanner has one workflow per scanned branch + (.github/workflows/ci-status-main.md → 'main' → 'ci-scan'; + .github/workflows/ci-status-net11.md → 'net11.0' → 'ci-scan-net11'). + Label name fully encodes the branch — no need to crack open the + issue body to figure out where it came from. + + Mapping: + main → ci-scan + netN.0 → ci-scan-netN + release/N.0.xx-previewM → ci-scan-netN (upstream) + release/N.0.xx-srM → $null (no scanner) + anything else → $null (no scanner) + + Preview branches return the parent net.0 label so an in-flight + preview readiness check still surfaces signals from the branch + the preview was cut from — the per-branch ci-status-*.md workflow + runs against net.0, not the preview branch. + + Add a case here when a new ci-status-*.md workflow is introduced. + Must be kept in sync with the matching helper in + scripts/Get-ReleaseReadiness.ps1. + #> + param([string]$Branch) + + if ([string]::IsNullOrWhiteSpace($Branch)) { return $null } + if ($Branch -eq 'main') { return 'ci-scan' } + if ($Branch -match '^net(\d+)\.0$') { return "ci-scan-net$($Matches[1])" } + if ($Branch -match '^release/(\d+)\.0\.\d+xx-preview\d+$') { + return "ci-scan-net$($Matches[1])" + } + return $null +} + +function Get-CiScanIssues { + <# + .SYNOPSIS + Returns open ci-scan issues for the scanner attached to $Branch. + Returns @{ Matched=[array]; FilteredOut=int; Total=int; + QueryFailed=[bool]; ScannerLabel=[string]|$null }. + .DESCRIPTION + Uses Get-CiScanLabelForBranch to resolve the single relevant label + (e.g. net11.0 → ci-scan-net11) and queries only that one — no more + cross-branch dedup or body marker parsing. When the branch has no + scanner, ScannerLabel is $null and Matched is empty. + + QueryFailed flips $true if the underlying `gh issue list` call + throws after retries. Callers must treat that case as "no signal" + rather than "no issues" to avoid emitting a false-green READY on + tool failure. + #> + param([string]$Branch) + + $label = Get-CiScanLabelForBranch -Branch $Branch + if (-not $label) { + return @{ + Matched = @() + FilteredOut = 0 + Total = 0 + QueryFailed = $false + ScannerLabel = $null + } + } + + try { + $batch = Get-IssuesByLabel -Label $label -IncludeBody + } catch { + return @{ + Matched = @() + FilteredOut = 0 + Total = 0 + QueryFailed = $true + ScannerLabel = $label + } + } + + $sorted = @($batch | Sort-Object { + $u = ConvertTo-UtcDateTime -Value $_.createdAt + if ($u) { $u } else { [DateTime]::MinValue } + } -Descending) + + return @{ + Matched = $sorted + FilteredOut = 0 + Total = $sorted.Count + QueryFailed = $false + ScannerLabel = $label + } +} + +function Test-IssueReleaseRelevant { + <# + .SYNOPSIS + Returns $true if a labelled issue is plausibly relevant to the + active major / preview number based on its title, milestone, or + labels. + .NOTES + Uses a wide net on purpose — false negatives are worse than false + positives for release-readiness triage. + #> + param( + $Issue, + [int]$Major, + [int]$Preview + ) + + $labels = @($Issue.labels | ForEach-Object { $_.name }) + $milestone = if ($Issue.milestone -and $Issue.milestone.title) { $Issue.milestone.title } else { "" } + $haystack = "$($Issue.title) $milestone $($labels -join ' ')" + + $majorRx = "(?i)net\s*$Major|net$Major|$Major\.0|$Major\.0\.1xx|xcode" + if ($haystack -match $majorRx) { + return $true + } + + if ($haystack -match "(?i)preview\s*$Preview|preview$Preview") { + return $true + } + + return $false +} + +function Get-ReleaseRelevantIssuesByLabel { + param( + [string[]]$Labels, + [int]$Major, + [int]$Preview + ) + + $issues = @() + foreach ($label in $Labels) { + $issues += Get-IssuesByLabel -Label $label + } + + $deduped = $issues | + Sort-Object number -Unique | + Where-Object { Test-IssueReleaseRelevant -Issue $_ -Major $Major -Preview $Preview } + + # PowerShell unwraps single-element arrays on function return, so a + # naked `return @($deduped)` with a $null/empty pipeline result yields + # $null at the call site (then `.Count` blows up under StrictMode). + # The leading comma forces a single-element outer array containing our + # real array, which PowerShell unwraps to the inner array — preserving + # the array type even when empty. + if ($null -eq $deduped) { return ,@() } + return ,@($deduped) +} + +function Test-IssueIsFresh { + <# + .SYNOPSIS + Returns $true if the issue was created within the last $HoursThreshold + hours. Used to escalate ci-scan checks to WATCH when scanner activity + is recent. + #> + param($Issue, [int]$HoursThreshold = 24) + + if (-not $Issue.PSObject.Properties['createdAt'] -or -not $Issue.createdAt) { return $false } + $createdUtc = ConvertTo-UtcDateTime -Value $Issue.createdAt + if (-not $createdUtc) { return $false } + return ((Get-Date).ToUniversalTime() - $createdUtc).TotalHours -lt $HoursThreshold +} + +function ConvertTo-UtcDateTime { + <# + .SYNOPSIS + Normalizes a value that may be a DateTime (Utc/Local/Unspecified) or a + string into a UTC DateTime. Returns $null if conversion fails. + .NOTES + ConvertFrom-Json parses ISO-8601 'Z' strings into DateTime with Kind=Utc, + but [DateTime]::Parse on a string returns Kind=Unspecified, which + .ToUniversalTime() then misinterprets as Local — silently shifting by + the host's UTC offset. Use this helper everywhere age is computed. + #> + param([object]$Value) + + if ($null -eq $Value) { return $null } + + if ($Value -is [DateTime]) { + if ($Value.Kind -eq [DateTimeKind]::Utc) { return $Value } + if ($Value.Kind -eq [DateTimeKind]::Local) { return $Value.ToUniversalTime() } + return [DateTime]::SpecifyKind($Value, [DateTimeKind]::Utc) + } + + try { + $dto = [DateTimeOffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture) + return $dto.UtcDateTime + } catch { + return $null + } +} + +function Get-PRAction { + <# + .SYNOPSIS + Maps PR state to a {Status, Action, Age} verdict. + #> + param($PR) + + $labels = @($PR.labels | ForEach-Object { $_.name }) + $ageDays = [Math]::Round(((Get-Date) - [DateTime]::Parse($PR.createdAt, [Globalization.CultureInfo]::InvariantCulture)).TotalDays) + + if ($PR.isDraft) { + return [PSCustomObject]@{ Status = "WATCH"; Action = "Draft PR; wait until ready for review."; Age = $ageDays } + } + if ($labels -contains "do-not-merge") { + return [PSCustomObject]@{ Status = "BLOCKED"; Action = "do-not-merge label present; resolve blocker before release."; Age = $ageDays } + } + if ($PR.mergeStateStatus -eq "DIRTY") { + return [PSCustomObject]@{ Status = "BLOCKED"; Action = "Resolve merge conflicts."; Age = $ageDays } + } + if ($PR.reviewDecision -eq "APPROVED") { + return [PSCustomObject]@{ Status = "WATCH"; Action = "Approved; verify release owner is ready to merge when CI/release gates allow."; Age = $ageDays } + } + if ($PR.reviewDecision -eq "CHANGES_REQUESTED") { + return [PSCustomObject]@{ Status = "BLOCKED"; Action = "Changes requested; author/release owner follow-up required."; Age = $ageDays } + } + return [PSCustomObject]@{ Status = "WATCH"; Action = "Needs review or triage."; Age = $ageDays } +} + +function New-Check { + param( + [string]$Area, + [string]$Status, + [string]$Details, + [string]$NextAction + ) + + return [PSCustomObject]@{ + Area = $Area + Status = $Status + Details = $Details + NextAction = $NextAction + } +} + +function Get-OverallStatus { + param([array]$Checks) + + $worst = "READY" + foreach ($check in $Checks) { + if ($StatusRank[$check.Status] -gt $StatusRank[$worst]) { + $worst = $check.Status + } + } + return $worst +} + +function Format-MarkdownCell { + param([string]$Value) + if ($null -eq $Value) { + return "" + } + # Escape `<`/`>` so user-controlled cell content (issue/PR titles) cannot + # inject an HTML comment. A title like `` + # would otherwise render verbatim ABOVE the human-notes block, where the + # workflow's hash-extraction (`sed '/begin/q' | grep ...`) would capture it as + # the semantic hash — freezing the Preview tracker (which emits no hash of its + # own) via OLD_HASH==NEW_HASH. Escaping also fixes legitimate titles such as + # `List` that GitHub markdown would otherwise swallow as an HTML tag. The + # engine's own markers are emitted via AppendLine, not through this formatter, + # so escaping cells never disturbs them. + return (($Value -replace "\|", "\|") -replace "<", "<" -replace ">", ">").Trim() +} + +function Format-GitHubHandle { + <# + .SYNOPSIS Render a GitHub login as a code span so it does NOT trigger an @-mention notification. + .DESCRIPTION + GitHub treats `@username` in issue/PR bodies as a notification mention. To safely surface + an author's handle in a report (without spamming them on every nightly run), wrap the + login in backticks: `` `username` `` is rendered as a code span and is NOT interpreted as a mention. + Handles bot/app refs (e.g. ``app/dotnet-maestro``) as well. + .PARAMETER Login + The raw GitHub login (with or without a leading ``@``). May be ``$null`` / empty. + .PARAMETER Fallback + Text to return when Login is null/empty. Defaults to ``unknown``. + #> + param( + [Parameter(Mandatory = $false)][AllowNull()][AllowEmptyString()][string]$Login, + [string]$Fallback = 'unknown' + ) + if ([string]::IsNullOrWhiteSpace($Login)) { return $Fallback } + $clean = $Login.TrimStart('@').Trim() + if ([string]::IsNullOrWhiteSpace($clean)) { return $Fallback } + return "``$clean``" +} + +function Add-CheckTable { + param( + [System.Text.StringBuilder]$Builder, + [array]$Checks + ) + + [void]$Builder.AppendLine("| Area | Status | Details | Next action |") + [void]$Builder.AppendLine("|------|--------|---------|-------------|") + foreach ($check in $Checks) { + [void]$Builder.AppendLine("| $(Format-MarkdownCell $check.Area) | **$($check.Status)** | $(Format-MarkdownCell $check.Details) | $(Format-MarkdownCell $check.NextAction) |") + } + [void]$Builder.AppendLine("") +} + +function Add-PRTable { + param( + [System.Text.StringBuilder]$Builder, + [array]$PRs, + [int]$MaxRows = 100 + ) + + if ($PRs.Count -eq 0) { + [void]$Builder.AppendLine("_None found._") + [void]$Builder.AppendLine("") + return + } + + [void]$Builder.AppendLine("| PR | Title | Author | Base | State | Age | Next action |") + [void]$Builder.AppendLine("|----|-------|--------|------|-------|-----|-------------|") + $rows = @($PRs | Select-Object -First $MaxRows) + foreach ($pr in $rows) { + $action = Get-PRAction -PR $pr + $author = Format-GitHubHandle -Login $pr.author.login + [void]$Builder.AppendLine("| [#$($pr.number)]($($pr.url)) | $(Format-MarkdownCell $pr.title) | $author | ``$($pr.baseRefName)`` | **$($action.Status)** | $($action.Age)d | $(Format-MarkdownCell $action.Action) |") + } + if ($PRs.Count -gt $MaxRows) { + [void]$Builder.AppendLine("") + [void]$Builder.AppendLine("_Showing $MaxRows of $($PRs.Count) PRs._") + } + [void]$Builder.AppendLine("") +} + +function Add-IssueTable { + param( + [System.Text.StringBuilder]$Builder, + [array]$Issues + ) + + if ($Issues.Count -eq 0) { + [void]$Builder.AppendLine("_None found._") + [void]$Builder.AppendLine("") + return + } + + [void]$Builder.AppendLine("| Issue | Title | Labels | Milestone |") + [void]$Builder.AppendLine("|-------|-------|--------|-----------|") + foreach ($issue in $Issues) { + $labels = (@($issue.labels | ForEach-Object { $_.name }) -join ", ") + $milestone = if ($issue.milestone -and $issue.milestone.title) { $issue.milestone.title } else { "" } + [void]$Builder.AppendLine("| [#$($issue.number)]($($issue.url)) | $(Format-MarkdownCell $issue.title) | $(Format-MarkdownCell $labels) | $(Format-MarkdownCell $milestone) |") + } + [void]$Builder.AppendLine("") +} + +function Add-CiScanTable { + <# + .SYNOPSIS + Renders open ci-scan issues with creation age. Fresh issues (<24h) + are visually flagged with 🆕 so release captains can spot recent + scanner activity at a glance. Sorted newest-first; capped at $MaxRows. + #> + param( + [System.Text.StringBuilder]$Builder, + [array]$Issues, + [int]$MaxRows = 15 + ) + + if ($Issues.Count -eq 0) { + [void]$Builder.AppendLine("_No open ``ci-scan`` issues — scanner has not flagged recurring CI failures recently._") + [void]$Builder.AppendLine("") + return + } + + [void]$Builder.AppendLine("| Issue | Title | Filed |") + [void]$Builder.AppendLine("|-------|-------|-------|") + $rows = $Issues | Select-Object -First $MaxRows + foreach ($issue in $rows) { + $marker = "" + $ageDisplay = "—" + if ($issue.PSObject.Properties['createdAt'] -and $issue.createdAt) { + $createdUtc = ConvertTo-UtcDateTime -Value $issue.createdAt + if ($createdUtc) { + $hoursAgo = ((Get-Date).ToUniversalTime() - $createdUtc).TotalHours + $ageDisplay = if ($hoursAgo -lt 24) { + "{0:N0}h ago" -f $hoursAgo + } else { + "{0:N0}d ago" -f ($hoursAgo / 24) + } + if ($hoursAgo -lt 24) { $marker = "🆕 " } + } + } + [void]$Builder.AppendLine("| $marker[#$($issue.number)]($($issue.url)) | $(Format-MarkdownCell $issue.title) | $ageDisplay |") + } + if ($Issues.Count -gt $MaxRows) { + [void]$Builder.AppendLine("") + [void]$Builder.AppendLine("_…and $($Issues.Count - $MaxRows) more. Full list: [open ci-scan issues](https://github.com/$Repository/issues?q=is%3Aopen+is%3Aissue+label%3Aci-scan+sort%3Acreated-desc)._") + } + [void]$Builder.AppendLine("") +} + +# =================================================================== +# MAIN — gather checks +# =================================================================== + +$checks = @() + +# --- Target branch existence --- +$targetBranchExists = Test-BranchExists -BranchName $Branch +if ($Mode -eq 'candidate') { + if ($targetBranchExists) { + # Branch already exists; if Find-Trackers ran today it would have + # classified this as in-flight. Inform the operator but don't fail. + $checks += New-Check -Area "Target branch" -Status "WATCH" -Details "``$Branch`` already exists — preview was cut. Re-run Find-Trackers to switch this tracker to in-flight mode." -NextAction "Re-run Find-ReleaseReadinessTrackers and update the workflow input." + } else { + $checks += New-Check -Area "Target branch (candidate)" -Status "READY" -Details "``$Branch`` does not exist yet — surveying source ``$SurveyRef`` (candidate mode)." -NextAction "Cut ``$Branch`` from ``$SurveyRef`` when ready." + } +} else { + if ($targetBranchExists) { + $checks += New-Check -Area "Target branch" -Status "READY" -Details "``$Branch`` exists." -NextAction "Continue release-readiness checks." + } else { + $checks += New-Check -Area "Target branch" -Status "BLOCKED" -Details "``$Branch`` does not exist." -NextAction "Create or select the correct release branch before declaring readiness." + } +} + +# --- Iteration check --- +# In-flight: surveyRef == Branch, so we check that the branch itself declares +# PreReleaseVersionIteration == previewNumber. +# Candidate: surveyRef == net.0, so we check that the source branch +# is bumped to match THIS preview (the one about to be cut). +$surveyIteration = $null +$xcodeRequirements = [PSCustomObject]@{ RequiredXcode = $null; DeviceTestsRequiredXcode = $null } +$surveyExists = if ($SurveyRef -eq $Branch) { $targetBranchExists } else { Test-BranchExists -BranchName $SurveyRef } +if ($surveyExists) { + try { + $surveyIteration = Get-PreReleaseVersionIteration -BranchName $SurveyRef + $iterArea = if ($Mode -eq 'candidate') { "$SurveyRef preview iteration (candidate source)" } else { "Preview iteration" } + if ($surveyIteration -eq [string]$previewNumber) { + $checks += New-Check -Area $iterArea -Status "READY" -Details "``$SurveyRef`` has PreReleaseVersionIteration=$surveyIteration." -NextAction "No version-iteration action needed." + } else { + $displayValue = if ($surveyIteration) { $surveyIteration } else { "" } + $checks += New-Check -Area $iterArea -Status "BLOCKED" -Details "``$SurveyRef`` has PreReleaseVersionIteration=$displayValue; expected $previewNumber." -NextAction "Bump ``$SurveyRef`` to match the preview number before cutting." + } + } catch { + $checks += New-Check -Area "Preview iteration" -Status "UNKNOWN" -Details "Could not read version iteration from ``$SurveyRef``." -NextAction "Run locally and inspect eng/Versions.props." + } + + try { + $xcodeRequirements = Get-XcodeRequirements -BranchName $SurveyRef + } catch { + $checks += New-Check -Area "Xcode variables" -Status "UNKNOWN" -Details "Could not read required Xcode variables from ``$SurveyRef``." -NextAction "Inspect eng/pipelines/common/variables.yml on ``$SurveyRef``." + } + + # --- Bug template version listing check --- + # Releasing a preview that's not in .github/ISSUE_TEMPLATE/bug-report.yml's + # `version-with-bug` dropdown means users can't file targeted bug reports + # against it. Read the template from main (issue templates are global per repo) + # and verify the dropdown contains an entry matching this preview. + try { + $expectedVersion = "$majorVersion.0.0-preview.$previewNumber" + $templateBranch = if ($mainBranch) { $mainBranch } else { 'main' } + $templateVersions = Get-BugTemplateVersions -BranchName $templateBranch + if ($templateVersions.Count -eq 0) { + $checks += New-Check -Area "Bug template versions" -Status "UNKNOWN" -Details "Could not read .github/ISSUE_TEMPLATE/bug-report.yml from ``$templateBranch`` or its version-with-bug dropdown is empty." -NextAction "Inspect the bug template manually." + } elseif ($templateVersions -contains $expectedVersion) { + $checks += New-Check -Area "Bug template versions" -Status "READY" -Details "``$expectedVersion`` listed in bug-report.yml on ``$templateBranch``." -NextAction "No action needed." + } else { + $sample = ($templateVersions | Select-Object -First 3) -join ', ' + $checks += New-Check -Area "Bug template versions" -Status "CLEANUP" -Details "``$expectedVersion`` NOT in .github/ISSUE_TEMPLATE/bug-report.yml version-with-bug dropdown on ``$templateBranch``. Top entries: $sample." -NextAction "Add ``$expectedVersion`` to the dropdown (PR against ``$templateBranch``). Not release-blocking — this is post-release cleanup so users can file bugs against the right version." + } + } catch { + $checks += New-Check -Area "Bug template versions" -Status "UNKNOWN" -Details "Failed to evaluate bug template: $($_.Exception.Message)" -NextAction "Inspect .github/ISSUE_TEMPLATE/bug-report.yml manually." + } +} + +# --- Inflight branch (net.0) bump check --- +# In-flight mode: surveyRef == Branch, so net.0 should be on N+1 (next preview). +# Candidate mode: surveyRef == net.0 already (and we just checked it +# declares iteration N above), so net.0 IS the source for this preview +# and the bump-to-N+1 conversation comes AFTER this preview ships. +$inflightIteration = $null +$inflightExists = Test-BranchExists -BranchName $mainBranch +if ($Mode -eq 'in-flight') { + if ($inflightExists) { + try { + $inflightIteration = Get-PreReleaseVersionIteration -BranchName $mainBranch + $displayValue = if ($inflightIteration) { $inflightIteration } else { "" } + if ($inflightIteration -and ([int]$inflightIteration -le $previewNumber)) { + $checks += New-Check -Area "$mainBranch preview-next bump" -Status "BLOCKED" -Details "``$mainBranch`` PreReleaseVersionIteration is $displayValue; target preview is $previewNumber." -NextAction "Confirm ``$mainBranch`` is bumped for preview-next." + } else { + $checks += New-Check -Area "$mainBranch preview-next bump" -Status "WATCH" -Details "``$mainBranch`` PreReleaseVersionIteration is $displayValue." -NextAction "Confirm this is correct for the next preview train." + } + } catch { + $checks += New-Check -Area "$mainBranch preview-next bump" -Status "UNKNOWN" -Details "Could not read $mainBranch PreReleaseVersionIteration." -NextAction "Run locally and inspect eng/Versions.props on $mainBranch." + } + } else { + $checks += New-Check -Area "$mainBranch branch" -Status "UNKNOWN" -Details "``$mainBranch`` branch was not found." -NextAction "Confirm branch state before release." + } +} + +# --- Open PRs --- +# "Target PRs" = PRs against the survey ref (the branch we're actually +# reporting readiness on; same as $Branch in in-flight mode, $mainBranch +# in candidate mode). +# "Inflight PRs" = PRs against net.0, ONLY surfaced when +# surveyRef != mainBranch (otherwise these are the same set). +$targetPRs = @() +$inflightPRs = @() +if ($surveyExists) { + $targetPRs = Get-OpenPullRequests -BaseBranch $SurveyRef +} +if ($SurveyRef -ne $mainBranch -and $inflightExists) { + $inflightPRs = Get-OpenPullRequests -BaseBranch $mainBranch +} + +$allReleasePRs = @($targetPRs) + @($inflightPRs) +$maestroPRs = @($allReleasePRs | Where-Object { $_.author -and $_.author.login -match "dotnet-maestro" }) +$targetHumanPRsRaw = @($targetPRs | Where-Object { -not ($_.author -and $_.author.login -match "dotnet-maestro") }) +$inflightHumanPRs = @($inflightPRs | Where-Object { -not ($_.author -and $_.author.login -match "dotnet-maestro") }) + +# Carve out main → $SurveyRef merge-up PRs from the human-PR set so they're +# only counted/listed once (in the hoisted "🔴 High-priority items" section) +# instead of double-counted as generic "Release branch 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 { + ($_.headRefName -and $_.headRefName -match '^merge/.+-to-') -or + ($_.title -and $_.title -match '^\[automated\] Merge branch') +}) +$mergeUpPrNumbers = @($mergeUpPRs | ForEach-Object { $_.number }) +$targetHumanPRs = @($targetHumanPRsRaw | Where-Object { $mergeUpPrNumbers -notcontains $_.number }) + +if ($maestroPRs.Count -eq 0) { + $checks += New-Check -Area "Maestro PRs" -Status "READY" -Details "No open Maestro PRs target ``$SurveyRef`` or ``$mainBranch``." -NextAction "Continue monitoring for new dependency-flow PRs." +} elseif (@($maestroPRs | Where-Object { (Get-PRAction -PR $_).Status -eq "BLOCKED" }).Count -gt 0) { + $checks += New-Check -Area "Maestro PRs" -Status "BLOCKED" -Details "$($maestroPRs.Count) open Maestro PR(s), including blocked/conflicted PRs." -NextAction "Resolve blocked Maestro PRs before release." +} else { + $checks += New-Check -Area "Maestro PRs" -Status "WATCH" -Details "$($maestroPRs.Count) open Maestro PR(s) need review/merge triage." -NextAction "Review dependency PRs and merge expected updates." +} + +if ($targetHumanPRs.Count -eq 0) { + $checks += New-Check -Area "Release branch PRs" -Status "READY" -Details "No non-Maestro open PRs target ``$SurveyRef``." -NextAction "No direct release-branch PR action from this check." +} else { + # Generic open PRs are NOT release blockers — only P/0 issues block the + # release (and those have a dedicated check above + hoisted section). + # PRs with merge conflicts or do-not-merge labels are normal queue + # noise: the captain decides per-PR if any specific one MUST merge. + $blockedCount = @($targetHumanPRs | Where-Object { (Get-PRAction -PR $_).Status -eq "BLOCKED" }).Count + $blockedNote = if ($blockedCount -gt 0) { " ($blockedCount with merge conflicts / do-not-merge label)" } else { "" } + $checks += New-Check -Area "Release branch PRs" -Status "WATCH" -Details "$($targetHumanPRs.Count) non-Maestro PR(s) target ``$SurveyRef``$blockedNote. Not auto-blocking — only P/0 issues block shipment." -NextAction "Confirm which PRs (if any) must merge for the release; the rest can ride normal queue cadence." +} + +# Inflight watch only matters when survey != inflight (otherwise it +# duplicates the target check). +if ($SurveyRef -ne $mainBranch) { + if ($inflightHumanPRs.Count -eq 0) { + $checks += New-Check -Area "$mainBranch inflight branch health" -Status "READY" -Details "No non-Maestro inflight PRs are open on ``$mainBranch``." -NextAction "Continue monitoring inflight branch health." + } elseif (@($inflightHumanPRs | Where-Object { (Get-PRAction -PR $_).Status -eq "BLOCKED" }).Count -gt 0) { + $checks += New-Check -Area "$mainBranch inflight branch health" -Status "WATCH" -Details "$($inflightHumanPRs.Count) non-Maestro PR(s) are open on ``$mainBranch``, including blocked PRs." -NextAction "Track as preview-next/inflight work; do not treat every inflight PR as a direct blocker for this release branch." + } else { + $checks += New-Check -Area "$mainBranch inflight branch health" -Status "WATCH" -Details "$($inflightHumanPRs.Count) non-Maestro PR(s) are open on ``$mainBranch``." -NextAction "Review inflight queue for preview-next readiness." + } +} + +# --- Release-relevant issues --- +$priorityIssues = Get-ReleaseRelevantIssuesByLabel -Labels @("p/0", "p/1") -Major $majorVersion -Preview $previewNumber +$kbeIssues = Get-ReleaseRelevantIssuesByLabel -Labels @("Known Build Error") -Major $majorVersion -Preview $previewNumber + +# Carve out P/0 issues separately — these are surfaced in the hoisted +# "🔴 High-priority items" section at the top of the report so the release +# captain sees them before any other content. P/1 issues still flow through +# the regular "Priority blockers" check below. +$p0Issues = @($priorityIssues | Where-Object { + @($_.labels | ForEach-Object { $_.name }) -contains 'p/0' +}) + +if ($p0Issues.Count -gt 0) { + $checks += New-Check -Area "P/0 priority blockers" -Status "BLOCKED" -Details "$($p0Issues.Count) open P/0 issue(s) look release-relevant. See 🔴 High-priority items at top." -NextAction "Resolve or downgrade each P/0 before shipping." +} else { + $checks += New-Check -Area "P/0 priority blockers" -Status "READY" -Details "No open release-relevant P/0 issues found." -NextAction "Confirm with release owners." +} + +$p1Issues = @($priorityIssues | Where-Object { + -not (@($_.labels | ForEach-Object { $_.name }) -contains 'p/0') +}) +if ($p1Issues.Count -gt 0) { + $checks += New-Check -Area "P/1 priority blockers" -Status "WATCH" -Details "$($p1Issues.Count) open P/1 issue(s) look release-relevant." -NextAction "Triage whether each blocks this release target." +} else { + $checks += New-Check -Area "P/1 priority blockers" -Status "READY" -Details "No open release-relevant P/1 issues found by public search." -NextAction "No action required." +} + +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." +} else { + $checks += New-Check -Area "Merge-up PRs (main → $SurveyRef)" -Status "READY" -Details "No open merge-up PRs from ``main`` → ``$SurveyRef``." -NextAction "Continue monitoring." +} + +if ($kbeIssues.Count -gt 0) { + $checks += New-Check -Area "Known Build Errors" -Status "WATCH" -Details "$($kbeIssues.Count) open release-relevant KBE issue(s) found." -NextAction "Use #35052 CI truth to decide accepted-known vs release-blocking." +} else { + $checks += New-Check -Area "Known Build Errors" -Status "READY" -Details "No release-relevant open KBE issues found by public search." -NextAction "Continue monitoring." +} + +# --- ci-scan signals (auto-filed by CI Failure Scanner every 12h) --- +# Filtered to issues whose body marker `**Branch**: ` matches the +# survey ref — repo-wide scanner signals from other branches (e.g. main +# failures when we're surveying net11.0) are excluded as not relevant. +# Fresh issues (created in last 24h) escalate to WATCH so release captains +# notice that the scanner just found something on this branch. +# Branch-scoped (was: dedup-and-filter; now: one label lookup via +# Get-CiScanLabelForBranch). For in-flight previews the parent net.0 +# scanner is queried; for SR-style refs there is no scanner and we surface +# that fact explicitly instead of a misleading "no signals". gh failures +# escalate to WATCH so a missing query doesn't silently READY the verdict. +$ciScanResult = Get-CiScanIssues -Branch $SurveyRef +$ciScanIssues = @($ciScanResult.Matched) +$ciScanFilteredOut = $ciScanResult.FilteredOut +$ciScanQueryFailed = [bool]$ciScanResult.QueryFailed +$ciScanLabel = $ciScanResult.ScannerLabel +$freshCiScan = @($ciScanIssues | Where-Object { Test-IssueIsFresh -Issue $_ -HoursThreshold 24 }) + +if ($ciScanQueryFailed) { + $checks += New-Check -Area "CI Failure Scanner signals" -Status "WATCH" ` + -Details "Could not query ci-scan issues (label ``$ciScanLabel`` — gh exited non-zero after retries). Treating as missing signal so the verdict reflects unknown state." ` + -NextAction "Verify ``gh auth status`` and rerun. If gh is unavailable, triage ci-scan manually." +} elseif (-not $ciScanLabel) { + $checks += New-Check -Area "CI Failure Scanner signals" -Status "READY" ` + -Details "No per-branch CI Failure Scanner is configured for ``$SurveyRef``. Add a case to Get-CiScanLabelForBranch if a scanner is added later." ` + -NextAction "No action — this branch is not continuously scanned." +} elseif ($freshCiScan.Count -gt 0) { + $detail = "$($freshCiScan.Count) ci-scan issue(s) on ``$SurveyRef`` (label ``$ciScanLabel``) filed in the last 24h ($($ciScanIssues.Count) total open). Likely affects this release." + $checks += New-Check -Area "CI Failure Scanner signals" -Status "WATCH" -Details $detail -NextAction "Review the freshest ci-scan issues; decide whether any affect ship-readiness." +} elseif ($ciScanIssues.Count -gt 0) { + $checks += New-Check -Area "CI Failure Scanner signals" -Status "WATCH" -Details "$($ciScanIssues.Count) open ci-scan issue(s) on ``$SurveyRef`` (label ``$ciScanLabel``, none filed in the last 24h)." -NextAction "Review recent ci-scan issues for ship-impact patterns." +} else { + $checks += New-Check -Area "CI Failure Scanner signals" -Status "READY" -Details "No open ci-scan issues on ``$SurveyRef`` (label ``$ciScanLabel``) — scanner has not flagged recurring CI failures." -NextAction "Continue monitoring." +} + +# --- CI truth (placeholder; #35052 wiring not yet done) --- +$checks += New-Check -Area "CI truth" -Status "INSUFFICIENT_DATA" -Details "#35052 structured CI evidence is not wired into this script yet." -NextAction "Do not infer release readiness from GitHub checks alone; consume #35052 output when available." + +# --- Xcode ICM --- +$requiredXcode = if ($xcodeRequirements.RequiredXcode) { $xcodeRequirements.RequiredXcode } else { "unknown" } +$deviceXcode = if ($xcodeRequirements.DeviceTestsRequiredXcode) { $xcodeRequirements.DeviceTestsRequiredXcode } else { "unknown" } +$checks += New-Check -Area "Xcode / ICM" -Status "UNKNOWN" -Details "REQUIRED_XCODE=$requiredXcode; DEVICETESTS_REQUIRED_XCODE=$deviceXcode." -NextAction "Verify hosted Mac pool support and file/update ICM immediately when public Xcode availability requires it." + +# --- Internal release pipelines (sanitized) --- +$internalStatus = "UNKNOWN" +$internalDetails = "Internal dnceng pipeline details are not queried in public workflow mode." +$internalAction = "Run this script locally with internal access, then publish only sanitized status." + +if ($IncludeInternal) { + if ([string]::IsNullOrWhiteSpace($InternalBuildId)) { + $internalStatus = "UNKNOWN" + $internalDetails = "Internal validation requested, but no InternalBuildId was provided." + $internalAction = "Run with -InternalBuildId or extend the local adapter for the target internal pipeline." + } elseif (Get-Command az -ErrorAction SilentlyContinue) { + try { + $azArgs = @( + "pipelines", "build", "show", + "--id", $InternalBuildId, + "--org", "https://dev.azure.com/dnceng", + "--project", "internal", + "--query", "{status:status,result:result}", + "-o", "json" + ) + $azOutput = & az @azArgs 2>$null + if ($LASTEXITCODE -eq 0 -and $azOutput) { + $internal = $azOutput | ConvertFrom-Json + if ($internal.status -eq "completed" -and $internal.result -eq "succeeded") { + $internalStatus = "READY" + $internalDetails = "Local internal validation found a completed/succeeded internal build." + $internalAction = "Keep detailed diagnostics internal; public issue may report READY." + } elseif ($internal.result) { + $internalStatus = "BLOCKED" + $internalDetails = "Local internal validation found an internal build that did not succeed." + $internalAction = "Release owner should inspect internal pipeline details ASAP." + } else { + $internalStatus = "WATCH" + $internalDetails = "Local internal validation found an internal build still in progress." + $internalAction = "Wait for completion or inspect internally if stale." + } + } else { + $internalStatus = "UNKNOWN" + $internalDetails = "Internal build query did not return usable status." + $internalAction = "Inspect internal Azure DevOps directly." + } + } catch { + $internalStatus = "UNKNOWN" + $internalDetails = "Internal validation failed locally." + $internalAction = "Inspect internal Azure DevOps directly; do not publish raw error details." + } + } else { + $internalStatus = "UNKNOWN" + $internalDetails = "Azure CLI is not available for local internal validation." + $internalAction = "Install/configure Azure CLI or inspect internal Azure DevOps directly." + } +} + +if ($PublicSafe -and $internalStatus -ne "READY") { + $internalDetails = "Internal release pipeline status is $internalStatus." + $internalAction = "Release owner should inspect dnceng/internal pipeline details ASAP." +} + +$checks += New-Check -Area "Internal release pipelines" -Status $internalStatus -Details $internalDetails -NextAction $internalAction + +$overallStatus = Get-OverallStatus -Checks $checks + +# =================================================================== +# REPORT ASSEMBLY +# =================================================================== +$generatedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") +$report = [PSCustomObject]@{ + GeneratedAt = $generatedAt + Repository = $Repository + Branch = $Branch + Mode = $Mode + SurveyRef = $SurveyRef + BranchType = "preview" + MajorVersion = $majorVersion + PreviewNumber = $previewNumber + InflightBranch = $mainBranch + TrackerKey = $TrackerKey + OverallStatus = $overallStatus + Checks = $checks + XcodeRequirements = $xcodeRequirements + MaestroPullRequests = $maestroPRs + ReleasePullRequests = $targetHumanPRs + InflightPullRequests = $inflightHumanPRs + PriorityIssues = $priorityIssues + KnownBuildErrorIssues = $kbeIssues + CiScanIssues = $ciScanIssues +} + +$md = [System.Text.StringBuilder]::new() +[void]$md.AppendLine("") +[void]$md.AppendLine("") +[void]$md.AppendLine("") +if ($Mode -eq 'candidate') { + [void]$md.AppendLine("# Release Readiness — .NET $majorVersion.0 preview $previewNumber (CANDIDATE from $SurveyRef) — $((Get-Date).ToString("yyyy-MM-dd"))") +} else { + [void]$md.AppendLine("# Release Readiness — .NET $majorVersion.0 preview $previewNumber — $((Get-Date).ToString("yyyy-MM-dd"))") +} +[void]$md.AppendLine("") +[void]$md.AppendLine("**Overall status:** **$overallStatus**") +[void]$md.AppendLine("") + +# === HIGH-PRIORITY ITEMS (hoisted to the very top) === +# Three categories the release captain must see BEFORE anything else: +# 1. P/0 priority blockers — open issues labeled p/0 (release-blocking severity). +# 2. Maestro dependency-flow PRs — open Maestro PRs against the survey ref. +# A stuck Maestro PR blocks all upstream dependency flow into this branch. +# 3. Merge-up PRs (main → 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. +# 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] +foreach ($iss in $p0Issues) { + [void]$highPriorityRows.Add(@{ + kind = '🔥 P/0 issue' + link = "[#$($iss.number)]($($iss.url))" + title = $iss.title + actor = if ($iss.milestone -and $iss.milestone.title) { $iss.milestone.title } else { '' } + nextAction = 'Resolve or downgrade before shipping.' + }) +} +foreach ($pr in $maestroPRs) { + $action = Get-PRAction -PR $pr + [void]$highPriorityRows.Add(@{ + kind = '📦 Maestro PR' + link = "[#$($pr.number)]($($pr.url))" + title = $pr.title + actor = "base ``$($pr.baseRefName)``, $($action.Age)d old" + nextAction = $action.Action + }) +} +foreach ($pr in $mergeUpPRs) { + $action = Get-PRAction -PR $pr + [void]$highPriorityRows.Add(@{ + kind = "🔀 Merge-up PR (main → $SurveyRef)" + link = "[#$($pr.number)]($($pr.url))" + title = $pr.title + actor = "base ``$($pr.baseRefName)``, $($action.Age)d old" + nextAction = $action.Action + }) +} + +if ($highPriorityRows.Count -gt 0) { + [void]$md.AppendLine("## 🔴 High-priority items — $($highPriorityRows.Count) item(s)") + [void]$md.AppendLine("") + [void]$md.AppendLine("_P/0 issues, Maestro PRs, and ``main`` → ``$SurveyRef`` merge-up PRs. Resolve these before treating the release as ready._") + [void]$md.AppendLine("") + [void]$md.AppendLine("| Kind | Item | Title | Context | Next action |") + [void]$md.AppendLine("|------|------|-------|---------|-------------|") + foreach ($row in $highPriorityRows) { + [void]$md.AppendLine("| $(Format-MarkdownCell $row.kind) | $($row.link) | $(Format-MarkdownCell $row.title) | $(Format-MarkdownCell $row.actor) | $(Format-MarkdownCell $row.nextAction) |") + } + [void]$md.AppendLine("") +} + +# === BLOCKING SUMMARY (hoisted to top) === +# Surface aggregate BLOCKED checks (e.g. CI red, versions.props not bumped). +# The three high-priority categories above already enumerate individual items, +# so exclude them here to avoid duplicate rows under two separate headings. +$highPriorityCheckAreas = @( + 'P/0 priority blockers', + "Merge-up PRs (main → $SurveyRef)" +) +$blockingChecks = @($checks | Where-Object { + $_.Status -eq 'BLOCKED' -and -not ($highPriorityCheckAreas -contains $_.Area) +}) +if ($blockingChecks.Count -gt 0) { + [void]$md.AppendLine("## 🔴 Blocking — $($blockingChecks.Count) item(s)") + [void]$md.AppendLine("") + [void]$md.AppendLine("| Area | Details | Next action |") + [void]$md.AppendLine("|------|---------|-------------|") + foreach ($bc in $blockingChecks) { + [void]$md.AppendLine("| $(Format-MarkdownCell $bc.Area) | $(Format-MarkdownCell $bc.Details) | $(Format-MarkdownCell $bc.NextAction) |") + } + [void]$md.AppendLine("") +} elseif ($highPriorityRows.Count -eq 0) { + [void]$md.AppendLine("## 🟢 No blocking items") + [void]$md.AppendLine("") +} + +# === CLEANUP FOLLOW-UPS (post-release housekeeping) === +# Items that are NOT release-blocking but are real follow-ups the release +# captain should track (e.g. bug template version dropdown not yet updated +# — that's a post-release cleanup, not a ship blocker). +$cleanupChecks = @($checks | Where-Object { $_.Status -eq 'CLEANUP' }) +if ($cleanupChecks.Count -gt 0) { + [void]$md.AppendLine("## 🧹 Cleanup follow-ups — $($cleanupChecks.Count) item(s)") + [void]$md.AppendLine("") + [void]$md.AppendLine("_Not release-blocking — these are post-ship housekeeping items to track separately._") + [void]$md.AppendLine("") + [void]$md.AppendLine("| Area | Details | Next action |") + [void]$md.AppendLine("|------|---------|-------------|") + foreach ($cc in $cleanupChecks) { + [void]$md.AppendLine("| $(Format-MarkdownCell $cc.Area) | $(Format-MarkdownCell $cc.Details) | $(Format-MarkdownCell $cc.NextAction) |") + } + [void]$md.AppendLine("") +} + +# === Recent CI Failure Scanner signals (hoisted near the top so signals +# specific to this release branch are surfaced before the deeper +# readiness checklist / PR tables) === +[void]$md.AppendLine("## Recent CI Failure Scanner signals (``ci-scan``)") +[void]$md.AppendLine("") +$ciScanBlurb = "_Filtered to issues whose ``**Branch**: `` body marker matches ``$SurveyRef`` (auto-filed by the CI Failure Scanner workflow every 12h). Fresh issues (<24h) are flagged 🆕._" +if ($ciScanFilteredOut -gt 0) { + $ciScanBlurb += " _$ciScanFilteredOut other-branch issue(s) were excluded as not relevant to this release._" +} +[void]$md.AppendLine($ciScanBlurb) +[void]$md.AppendLine("") +if ($ciScanIssues.Count -eq 0) { + [void]$md.AppendLine("_No ci-scan issues target ``$SurveyRef``._") + [void]$md.AppendLine("") +} else { + Add-CiScanTable -Builder $md -Issues $ciScanIssues +} + +[void]$md.AppendLine("Generated at $generatedAt for ``$Repository``.") +[void]$md.AppendLine("") +[void]$md.AppendLine("**Tracker:** ``$TrackerKey`` · mode=``$Mode`` · branch=``$Branch`` · survey=``$SurveyRef``") +[void]$md.AppendLine("") +if ($Mode -eq 'candidate') { + [void]$md.AppendLine("> 🛫 **Pre-flight (candidate) mode.** Branch ``$Branch`` has not been cut yet. This report surveys ``$SurveyRef`` and shows what WOULD ship if the preview were cut today.") + [void]$md.AppendLine("") +} +[void]$md.AppendLine("## Target") +[void]$md.AppendLine("") +[void]$md.AppendLine("| Field | Value |") +[void]$md.AppendLine("|-------|-------|") +[void]$md.AppendLine("| Branch | ``$Branch`` |") +[void]$md.AppendLine("| Inflight branch | ``$mainBranch`` |") +[void]$md.AppendLine("| Expected SDK channel | ``.NET $majorVersion.0.1xx SDK Preview $previewNumber`` |") +[void]$md.AppendLine("| Workload release channel | ``.NET $majorVersion Workload Release`` |") +[void]$md.AppendLine("| Expected PreReleaseVersionIteration | ``$previewNumber`` |") +[void]$md.AppendLine("") + +# Human-editable section, preserved across re-runs by workflow body merge. +# Built as a reusable block (like the SR engine) so the body-size cap below can +# strip it, truncate the remaining content, then re-append it — guaranteeing the +# begin/end markers always survive truncation regardless of section order. The +# "🔴 High-priority items" table above this block is itemized and uncapped, so a +# naive byte-prefix cut could otherwise drop these markers. +$notesSb = [System.Text.StringBuilder]::new() +[void]$notesSb.AppendLine("") +[void]$notesSb.AppendLine("## Release Captain Notes") +[void]$notesSb.AppendLine("") +[void]$notesSb.AppendLine("_Add manual notes here. Anything between these begin/end markers is preserved across automated re-runs._") +[void]$notesSb.AppendLine("") +$notesBlockText = $notesSb.ToString() +[void]$md.Append($notesBlockText) +[void]$md.AppendLine("") + +[void]$md.AppendLine("## Readiness checklist") +[void]$md.AppendLine("") +Add-CheckTable -Builder $md -Checks $checks + +[void]$md.AppendLine("## Maestro / dependency-flow PRs") +[void]$md.AppendLine("") +Add-PRTable -Builder $md -PRs $maestroPRs + +[void]$md.AppendLine("## Release branch PRs") +[void]$md.AppendLine("") +Add-PRTable -Builder $md -PRs $targetHumanPRs + +[void]$md.AppendLine("## $mainBranch inflight PRs") +[void]$md.AppendLine("") +Add-PRTable -Builder $md -PRs $inflightHumanPRs -MaxRows 30 + +[void]$md.AppendLine("## Priority release blockers") +[void]$md.AppendLine("") +Add-IssueTable -Builder $md -Issues $priorityIssues + +[void]$md.AppendLine("## Known Build Error watch list") +[void]$md.AppendLine("") +Add-IssueTable -Builder $md -Issues $kbeIssues + +[void]$md.AppendLine("## Maintainer next actions") +[void]$md.AppendLine("") +$nonReady = @($checks | Where-Object { $_.Status -ne "READY" }) +if ($nonReady.Count -eq 0) { + [void]$md.AppendLine("- No non-ready actions found by this public checklist.") +} else { + foreach ($check in $nonReady) { + [void]$md.AppendLine("- **$($check.Area)**: $($check.NextAction)") + } +} +[void]$md.AppendLine("") + +[void]$md.AppendLine("## Public/internal data boundary") +[void]$md.AppendLine("") +[void]$md.AppendLine("This public report intentionally omits internal logs, artifacts, private URLs, raw error text, secret names, account identifiers, and detailed dnceng/internal failure payloads. Use the local script with appropriate internal access for deeper validation.") +[void]$md.AppendLine("") + +$markdownBody = $md.ToString() + +# =================================================================== +# SAFETY NET: defang any remaining bare @-mentions in the final body. +# Primary defense is Format-GitHubHandle at emit time, but PR/issue +# titles or commit messages can contain raw `@user` references that +# would notify real users every time this report is filed. Wrap any +# `@handle` in backticks so GitHub renders it as a code span (no mention). +# =================================================================== +$markdownBody = [regex]::Replace( + $markdownBody, + '(^|[^a-zA-Z0-9/`])@([a-zA-Z0-9][a-zA-Z0-9_-]*(?:/[a-zA-Z0-9][a-zA-Z0-9_-]*)?)', + '$1`$2`' +) + +# =================================================================== +# BODY-SIZE SAFETY CAP +# =================================================================== +# GitHub rejects an issue body over 65,536 bytes; the daily refresh would then +# fail `gh issue edit` and the tracker would silently stop updating. The body +# has unbounded sections BOTH above the human-notes block (the itemized, +# uncapped "🔴 High-priority items" table) AND below it (the Maestro / release / +# inflight PR tables, rendered with Add-PRTable's default 100-row cap). A plain +# byte-prefix cut could therefore drop the notes begin/end markers — and a +# markerless fresh body makes the workflow skip the edit (freezing the tracker) +# or, worse, overwrite live Release Captain Notes. So we mirror the SR engine: +# strip the notes placeholder, truncate only the remaining content (reserving +# room for the notes block + message), boundary-repair, then RE-APPEND the notes +# block. This guarantees exactly one clean begin/end pair always survives for the +# workflow splice, independent of section order. The placeholder carries no human +# data (real notes live on the issue and are spliced in by the workflow), so +# removing and re-adding it is lossless. The tracker markers sit at the very top, +# well inside the reserved prefix, so they survive too. +$bodyBytes = [System.Text.Encoding]::UTF8.GetByteCount($markdownBody) +if ($bodyBytes -gt $MaxBodyBytes) { + $truncateMsg = "`n`n> ⚠️ **Report truncated** ($bodyBytes bytes exceeded cap of $MaxBodyBytes). See full data in workflow artifacts.`n" + $tail = [System.Text.Encoding]::UTF8.GetByteCount($truncateMsg) + $notesTail = "`n" + $notesBlockText + $notesReserve = [System.Text.Encoding]::UTF8.GetByteCount($notesTail) + $bodyNoNotes = $markdownBody.Replace($notesBlockText, '') + $targetLen = $MaxBodyBytes - $tail - $notesReserve + if ($targetLen -lt 0) { $targetLen = 0 } + $allBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyNoNotes) + if ($targetLen -gt $allBytes.Length) { $targetLen = $allBytes.Length } + $truncatedBytes = New-Object byte[] $targetLen + [Array]::Copy($allBytes, 0, $truncatedBytes, 0, $targetLen) + # UTF-8 boundary repair: drop a trailing INCOMPLETE multibyte sequence so + # GetString() doesn't emit a U+FFFD (which re-encodes to 3 bytes and could + # push the body back over the cap). Walk back over continuation bytes + # (10xxxxxx) to the lead byte, infer the sequence length, and cut at the + # lead only when the full sequence doesn't fit. + if ($truncatedBytes.Length -gt 0) { + $i = $truncatedBytes.Length - 1 + while ($i -ge 0 -and ($truncatedBytes[$i] -band 0xC0) -eq 0x80) { $i-- } + if ($i -ge 0) { + $lead = $truncatedBytes[$i] + $seqLen = if (($lead -band 0x80) -eq 0x00) { 1 } + elseif (($lead -band 0xE0) -eq 0xC0) { 2 } + elseif (($lead -band 0xF0) -eq 0xE0) { 3 } + elseif (($lead -band 0xF8) -eq 0xF0) { 4 } + else { 1 } + if (($i + $seqLen) -gt $truncatedBytes.Length) { + $newArr = New-Object byte[] $i + [Array]::Copy($truncatedBytes, 0, $newArr, 0, $i) + $truncatedBytes = $newArr + } + } + } + $markdownBody = [System.Text.Encoding]::UTF8.GetString($truncatedBytes) + $notesTail + $truncateMsg +} + +# =================================================================== +# OUTPUT +# =================================================================== +if ($OutputDir) { + if (-not (Test-Path $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null + } + $jsonPath = Join-Path $OutputDir "preview-readiness.json" + $mdPath = Join-Path $OutputDir "preview-readiness.md" + + $report | ConvertTo-Json -Depth 20 | Out-File -FilePath $jsonPath -Encoding utf8 + $markdownBody | Out-File -FilePath $mdPath -Encoding utf8 + + Write-Host "Wrote $jsonPath" + Write-Host "Wrote $mdPath" +} + +switch ($OutputFormat) { + "json" { + $report | ConvertTo-Json -Depth 20 + } + "both" { + if (-not $OutputDir) { + $report | ConvertTo-Json -Depth 20 + } + $markdownBody + } + default { + # "markdown" — if -OutputDir was given, the file is already on + # disk; still write the body to stdout so dispatchers can capture + # it inline. + $markdownBody + } +} diff --git a/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 new file mode 100644 index 000000000000..2bc8dd3c6d13 --- /dev/null +++ b/.github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 @@ -0,0 +1,3635 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# +.SYNOPSIS + Assesses release readiness of a .NET MAUI Servicing Release (SR) branch. + +.DESCRIPTION + Produces a deterministic, evidence-backed answer to "Is release/X.Y.Zxx-srN + ready to ship?" by: + + 1. Computing what is NEW in the SR (commits + source PR refs + reverts) + 2. Querying open `regressed-in-*` issues, walking timelines, and classifying + each candidate fix PR against SR contents + 3. Querying CI pipelines on the SR branch with freshness check + + All conclusions carry evidence (commit SHAs, PR numbers, ancestry checks) + and confidence levels. See references/methodology.md for the algorithms + and the three critical gotchas the skill encodes. + +.PARAMETER SrBranch + SR branch name (e.g. release/10.0.1xx-sr7). Required. + +.PARAMETER RegressionLabels + Comma-separated `regressed-in-*` label names. Required unless + -InferRegressionLabels is set. + +.PARAMETER InferRegressionLabels + Auto-derive labels from the SR's version family. Agent should ALWAYS + confirm the inferred labels with the user before using for automation. + +.PARAMETER Repo + Repository in owner/name form. Default: dotnet/maui. + +.PARAMETER MainBranch + Stable branch used for ancestry checks. Default: main. + +.PARAMETER ExcludeBranches + Comma-separated branches to exclude when computing SR-only commits. + Default: origin/main. Do NOT add inflight/* refs — SR branches cut from + main; comparing against inflight produces wrong "what's shipping" answers. + +.PARAMETER Candidate + Pre-flight / candidate mode. Use when the next SR branch doesn't exist + yet but you want to know "what WOULD ship in SRn+1 if cut from main + today?". With -Candidate, the script treats `origin/$MainBranch` as the + SR-to-be and uses the named -SrBranch as the prior-SR exclude baseline. + +.PARAMETER InheritFromPriorSr + Only valid with -Candidate. Models the dotnet/maui release workflow where + SRn+1 is cut from main AND then has SRn merged into it. The "what's + shipping" set = (main commits since prior SR) ∪ (prior SR-only commits). + Without this flag, candidate mode shows only main-since-priorSR. + +.PARAMETER Phase + Which phase to run: all (default), ci, commits, regressions, open-prs. + +.PARAMETER OutputDir + Directory for output files. If unset, prints to stdout. + +.PARAMETER OutputFormat + json, markdown, or both (default). + +.PARAMETER MaxIssues + Cap on regression issues to walk. Default: 100. + +.PARAMETER NoFetch + Skip `git fetch`. Use for re-runs with cached refs. + +.PARAMETER RepoUrl + Base URL of the repository web UI. Used to linkify commit SHAs and PR + numbers in the markdown report. Default: https://github.com/dotnet/maui. + +.PARAMETER TrackerKey + Canonical key used to identify the corresponding tracker issue (e.g. + `net10-sr7`). When set, the markdown report includes a hidden HTML + comment marker `` and a + visible "Tracker: …" line so a workflow can match a single tracker + issue per SR. Optional; omit for ad-hoc local reports. + +.PARAMETER MaxBodyBytes + Hard cap on the rendered markdown body. When the report exceeds this, + the script truncates and appends a single-line "[Report truncated. See + artifacts at .]" message. Default: 60000 (≈60KB, well under + GitHub's 65,536-byte issue body limit). + +.EXAMPLE + pwsh ./Get-ReleaseReadiness.ps1 -SrBranch release/10.0.1xx-sr7 ` + -RegressionLabels regressed-in-10.0.60,regressed-in-10.0.70 ` + -OutputDir CustomAgentLogsTmp/release-readiness/sr7 + +.EXAMPLE + pwsh ./Get-ReleaseReadiness.ps1 -SrBranch release/10.0.1xx-sr7 -Phase commits + +.EXAMPLE + # Pre-flight: what would SR8 contain if cut from main today? + pwsh ./Get-ReleaseReadiness.ps1 -SrBranch release/10.0.1xx-sr7 -Candidate ` + -RegressionLabels regressed-in-10.0.70,regressed-in-10.0.80 ` + -OutputDir CustomAgentLogsTmp/release-readiness/sr8-candidate + +.EXAMPLE + # Pre-flight SR8 modeling the SR7→SR8 merge workflow + pwsh ./Get-ReleaseReadiness.ps1 -SrBranch release/10.0.1xx-sr7 -Candidate ` + -InheritFromPriorSr ` + -RegressionLabels regressed-in-10.0.70,regressed-in-10.0.80 ` + -OutputDir CustomAgentLogsTmp/release-readiness/sr8-candidate +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$SrBranch, + [string]$RegressionLabels, + [switch]$InferRegressionLabels, + [string]$Repo = 'dotnet/maui', + [string]$MainBranch = 'main', + [string]$ExcludeBranches = 'origin/main', + [ValidateSet('all', 'ci', 'commits', 'regressions', 'open-prs')] + [string]$Phase = 'all', + [string]$OutputDir, + [ValidateSet('json', 'markdown', 'both')] + [string]$OutputFormat = 'both', + [int]$MaxIssues = 100, + [switch]$NoFetch, + # URL base for linkifying commit SHAs and PR numbers in the markdown + # report. Defaults to the public dotnet/maui repo; override for forks. + [string]$RepoUrl = 'https://github.com/dotnet/maui', + # Canonical key for the tracker issue (e.g. net10-sr7). When set, the + # markdown report embeds tracker + idempotency markers. Optional. + [string]$TrackerKey, + # Body-size cap (bytes) for markdown rendering. GitHub issue body limit + # is 65,536 bytes; default 60,000 leaves headroom for marker comments. + [int]$MaxBodyBytes = 60000, + # Candidate / pre-flight mode: survey what WOULD ship in the next SR if cut + # from main today. Requires -SrBranch to be the prior SR (used as the + # exclude baseline). Treats origin/main as the "SR-to-be". + [switch]$Candidate, + # When set in -Candidate mode, model the dotnet/maui workflow where, after + # cutting SRn+1 from main, the prior SR (-SrBranch) is merged in. The + # candidate's "what's shipping" set = main-since-priorSR ∪ priorSR-only commits. + # Without this flag, candidate mode shows only main-since-priorSR. + [switch]$InheritFromPriorSr, + # Skip Maestro/BAR operational checks (default-channel mapping + per-commit + # BAR build lookup). These run via `darc` CLI and require BAR auth. When darc + # isn't installed (e.g. minimal CI image), the checks auto-skip and emit + # UNKNOWN status with verification commands — this switch lets a caller force + # the skip even when darc IS available (e.g. known auth-failure environment). + [switch]$SkipMaestroChecks, + # Skip milestone hygiene checks (current+next milestone existence + stale-open + # milestone detection). Useful for repos that don't use milestone-per-release. + [switch]$SkipMilestoneChecks, + # Query internal (dnceng/internal) AzDO pipelines in addition to the public + # dnceng-public ones. Off by default: the public Actions runner has no + # internal AzDO credentials, so the query always returns 401 — which in + # turn permanently parks the verdict at 🟡 Conditionally Ready with a + # bogus "unknown" Tier 2 reason. Enable when running locally with AzDO + # auth (az login / PAT) and you actually want internal signal. + [switch]$IncludeInternal +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# 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 +# the two leads to wrong "what's shipping" conclusions. +$Script:ForbiddenSrPatterns = @( + '^inflight/' # inflight/current, inflight/candidate, inflight/ai — NOT SR sources + '^staging/' # any staging area + '^backport/' # in-progress backport branches +) + +# Public AzDO MAUI pipelines on dnceng-public +$Script:PublicPipelines = @( + @{ Name = 'maui-pr'; DefinitionId = 302; Org = 'dnceng-public'; Project = 'public' } + @{ Name = 'maui-pr-devicetests'; DefinitionId = 314; Org = 'dnceng-public'; Project = 'public' } + @{ Name = 'maui-pr-uitests'; DefinitionId = 313; Org = 'dnceng-public'; Project = 'public' } +) +# Internal signed build (best-effort — requires AzDO auth) +$Script:InternalPipelines = @( + @{ Name = 'dotnet-maui'; DefinitionId = 1095; Org = 'dnceng'; Project = 'internal' } +) + +$Script:Warnings = [System.Collections.Generic.List[string]]::new() + +function Write-Warn([string]$msg) { + $Script:Warnings.Add($msg) | Out-Null + Write-Host "warn: $msg" -ForegroundColor Yellow +} + +function Invoke-Git([string]$Cmd) { + $argList = $Cmd -split ' ' | Where-Object { $_ -ne '' } + $out = & git @argList 2>$null + if ($LASTEXITCODE -ne 0) { return $null } + return $out +} + +function Invoke-Gh([string[]]$GhArgs) { + $errFile = [System.IO.Path]::GetTempFileName() + try { + $out = & gh @GhArgs 2>$errFile + $exitCode = $LASTEXITCODE + if ($exitCode -ne 0) { + $err = Get-Content $errFile -Raw -ErrorAction SilentlyContinue + Write-Warn "gh $($GhArgs -join ' ') exited $exitCode : $err" + return $null + } + return $out + } finally { + if (Test-Path $errFile) { Remove-Item $errFile -ErrorAction SilentlyContinue } + } +} + +function Get-FileFromRef { + <# + .SYNOPSIS + Reads a file from the local repo at the given ref. Tries `git show` first (fast, + offline); falls back to `gh api` if the local ref isn't available. + #> + param([string]$Path, [string]$Ref) + $local = Invoke-Git "show ${Ref}:${Path}" + if ($local) { return ($local -join "`n") } + + # Strip leading origin/ for gh api ref + $apiRef = $Ref -replace '^origin/', '' + $encodedRef = [System.Uri]::EscapeDataString($apiRef) + $b64 = Invoke-Gh @('api', "repos/$($script:Repo)/contents/$Path`?ref=$encodedRef", + '--jq', '.content') + if (-not $b64) { return $null } + try { + return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(($b64 -replace '\s', ''))) + } catch { + return $null + } +} + +function Get-VersionsPropsState { + <# + .SYNOPSIS + Parses eng/Versions.props at $Ref and returns the version-bump state. + .DESCRIPTION + Returns @{ Major; Minor; Patch; PreReleaseVersionLabel; PreReleaseVersionIteration; + StabilizePackageVersion; FullVersion } or $null if the file is + unreadable. FullVersion is ".." — the version that this + branch's builds would emit. + #> + param([string]$Ref) + $content = Get-FileFromRef -Path 'eng/Versions.props' -Ref $Ref + if (-not $content) { return $null } + + function _Extract([string]$xml, [string]$tag) { + if ($xml -match "<$tag(?:\s[^>]*)?>\s*([^<]*)\s*") { return $Matches[1].Trim() } + return $null + } + + $major = _Extract $content 'MajorVersion' + $minor = _Extract $content 'MinorVersion' + $patch = _Extract $content 'PatchVersion' + if (-not $major -or -not $minor -or $null -eq $patch) { return $null } + + @{ + Major = [int]$major + Minor = [int]$minor + Patch = [int]$patch + PreReleaseVersionLabel = (_Extract $content 'PreReleaseVersionLabel') + PreReleaseVersionIteration = (_Extract $content 'PreReleaseVersionIteration') + StabilizePackageVersion = (_Extract $content 'StabilizePackageVersion') + FullVersion = "$major.$minor.$patch" + } +} + +function Get-BugTemplateVersions { + <# + .SYNOPSIS + Reads the version-with-bug dropdown from .github/ISSUE_TEMPLATE/bug-report.yml + at $Ref. See Get-PreviewReadiness for the matching helper. + #> + param([string]$Ref) + + $yaml = Get-FileFromRef -Path '.github/ISSUE_TEMPLATE/bug-report.yml' -Ref $Ref + if ([string]::IsNullOrWhiteSpace($yaml)) { return @() } + + $lines = $yaml -split "`n" + $inDropdown = $false + $inOptions = $false + $optionsIndent = -1 + $values = New-Object System.Collections.Generic.List[string] + + foreach ($rawLine in $lines) { + $line = $rawLine.TrimEnd("`r") + if (-not $inDropdown) { + if ($line -match '^\s*id:\s*version-with-bug\s*$') { $inDropdown = $true } + continue + } + if (-not $inOptions) { + if ($line -match '^(\s*)options:\s*$') { + $inOptions = $true + $optionsIndent = $Matches[1].Length + } + if ($line -match '^\s*-\s*type:\s*') { break } + continue + } + if ($line -match '^(\s*)-\s+(.+?)\s*$') { + $indent = $Matches[1].Length + if ($indent -gt $optionsIndent) { + $value = $Matches[2].Trim().Trim("'").Trim('"') + if (-not [string]::IsNullOrWhiteSpace($value)) { [void]$values.Add($value) } + continue + } + } + if ($line -match '^\s*$') { continue } + if ($line -match '^(\s*)\S' -and $Matches[1].Length -le $optionsIndent) { break } + } + return @($values) +} + +function Get-ExpectedShipDate { + <# + .SYNOPSIS + Returns the expected ship date for a .NET MAUI release. + .DESCRIPTION + Cadence depends on the PatchVersion being shipped: + - Multiples of 10 (80, 90, 100…) and previews → 2nd Tuesday of a month + (cross-team .NET convention — used by dotnet/sdk, runtime, MAUI, VS, etc.) + - Anything else (81, 82, 91…) → ASAP hotfix, no cadence + + Anchoring (which month's 2nd Tuesday?): + - If `-MainBumpDate` is provided, the anchor is the month immediately + AFTER main was bumped to this SR's cycle base PatchVersion. This is + the deterministic mapping the team actually uses: main bumped 70→80 + on 2026-05-13 → SR8 ships 2nd Tuesday of June 2026 = June 9. + - If no anchor: fall back to "next 2nd Tuesday from today". This is + only correct when readying the *current* SR before its window; + after the window passes, the fallback wrongly slides into the next + SR's slot. Production callers should always pass MainBumpDate. + + Returns [PSCustomObject]@{ + Cadence = 'second-tuesday' | 'second-tuesday-missed' | 'asap-hotfix' + Date = [DateTime] (UTC, 00:00) | $null when ASAP + DaysFromNow = [int] | $null (negative when the window passed) + FormattedLong = "Tuesday June 9, 2026" | "ASAP (hotfix patch)" + MissedWindow = [bool] + AnchorSource = 'main-bump' | 'fallback-current-month' | 'fallback-rolled' + Note = explanation string suitable for the report header + } + .NOTES + $ReferenceDate is for testability — production callers pass [DateTime]::UtcNow.Date. + $PatchVersion = $null → assume 2nd-Tuesday cadence (back-compat for callers + that don't know the patch yet). + #> + param( + [DateTime]$ReferenceDate = [DateTime]::UtcNow.Date, + [Nullable[int]]$PatchVersion = $null, + [Nullable[DateTime]]$MainBumpDate = $null + ) + # Hotfix patch (not a multiple of 10) → no cadence, ship ASAP. + if ($null -ne $PatchVersion -and ($PatchVersion % 10) -ne 0) { + return [PSCustomObject]@{ + Cadence = 'asap-hotfix' + Date = $null + DaysFromNow = $null + FormattedLong = 'ASAP (hotfix patch)' + MissedWindow = $false + AnchorSource = 'asap' + Note = "PatchVersion ``$PatchVersion`` is a hotfix on top of an existing release — ships as soon as ready, no 2nd-Tuesday wait." + } + } + + # 2nd-Tuesday cadence. + $today = $ReferenceDate.Date + + function _SecondTuesdayOf { + param([int]$Year, [int]$Month) + $first = [DateTime]::new($Year, $Month, 1) + # DayOfWeek: Sunday=0, Monday=1, Tuesday=2. Offset to reach the first Tuesday. + $offset = (2 - [int]$first.DayOfWeek + 7) % 7 + $firstTuesday = $first.AddDays($offset) + return $firstTuesday.AddDays(7) + } + + $anchorSource = $null + if ($MainBumpDate) { + # Anchor on the month AFTER main was bumped to this SR's cycle. + # Convention: main bumped to N*10 in month M → SR_N ships month (M+1). + $bumpedMonth = $MainBumpDate.Date.AddMonths(1) + $candidate = _SecondTuesdayOf -Year $bumpedMonth.Year -Month $bumpedMonth.Month + $anchorSource = 'main-bump' + } else { + # Fallback: "next 2nd Tuesday from today". Only safe BEFORE the window. + $candidate = _SecondTuesdayOf -Year $today.Year -Month $today.Month + $anchorSource = 'fallback-current-month' + if ($candidate -lt $today) { + $next = $today.AddMonths(1) + $candidate = _SecondTuesdayOf -Year $next.Year -Month $next.Month + $anchorSource = 'fallback-rolled' + } + } + + $daysFromNow = [int]($candidate - $today).TotalDays + $missedWindow = ($anchorSource -eq 'main-bump' -and $daysFromNow -lt 0) + + if ($missedWindow) { + $cadence = 'second-tuesday-missed' + $note = "Scheduled ship date for this SR was the 2nd Tuesday of $($candidate.ToString('MMMM yyyy')) (anchored on the main-bump for this cycle). That date has passed — coordinate with the release captain on the next valid window." + } else { + $cadence = 'second-tuesday' + $note = '.NET releases ship on the 2nd Tuesday of each month.' + } + + [PSCustomObject]@{ + Cadence = $cadence + Date = $candidate + DaysFromNow = $daysFromNow + FormattedLong = $candidate.ToString('dddd MMMM d, yyyy') + MissedWindow = $missedWindow + AnchorSource = $anchorSource + Note = $note + } +} + +function Get-MainBumpDateForCycle { + <# + .SYNOPSIS + Finds the date `origin/main` was bumped to a particular PatchVersion. + .DESCRIPTION + Walks `git log` for commits on main that ADDED `$CycleBase` + in eng/Versions.props, returning the MOST RECENT such commit. The date + of that commit anchors the SR's ship-date calculation: an SR with + cycle base N*10 ships the 2nd Tuesday of the month AFTER main bumped + to N*10. + + Critical caveats `git log -S` does NOT handle: + 1. `-S` matches commits where the count of the substring CHANGED — + so it matches both the "add 80" commit (70→80) AND the "remove + 80" commit (80→90). We need only the ADD commit. + 2. The same PatchVersion value (e.g. 80) recurs across major-version + cycles: MAUI 8.x, 9.x and 10.x each had a `80` + line at different points in history. If MajorVersion is provided, + we validate the commit had the matching `` value, + which eliminates the cross-major ambiguity entirely. + + Returns [PSCustomObject]@{ Sha; Date (UTC); Subject } or $null. + #> + param( + [Parameter(Mandatory)][int]$CycleBase, + [Nullable[int]]$MajorVersion = $null, + [string]$MainRef = 'origin/main' + ) + $needle = "$CycleBase" + try { + # Default order is newest-first. Walk candidates and pick the most + # recent one where the line was ADDED (not removed) AND, if requested, + # the MajorVersion at that commit matches. + $shas = git log -S $needle --pretty='%H' $MainRef -- eng/Versions.props 2>$null + if (-not $shas) { return $null } + foreach ($s in @($shas)) { + $sTrim = $s.Trim(); if (-not $sTrim) { continue } + + # Verify the diff ADDED the line (the bump event), not removed it + # (a subsequent re-bump that took us past this cycle). + $diff = git show --no-color --format= $sTrim -- eng/Versions.props 2>$null + $addedNeedle = $false + foreach ($line in ($diff -split "`r?`n")) { + if ($line -like "+*" -and $line -notlike "+++*" -and $line -match [regex]::Escape($needle)) { + $addedNeedle = $true; break + } + } + if (-not $addedNeedle) { continue } + + # Validate MajorVersion at that commit (eliminates cross-major collisions). + if ($null -ne $MajorVersion) { + $content = git show "$($sTrim):eng/Versions.props" 2>$null + if (-not $content) { continue } + # `git show` returns an [Object[]] of lines. Join to a single + # string so the regex match works against the whole file + # rather than per-line (where the MajorVersion match would + # never fire because each individual line doesn't contain it). + if ($content -is [array]) { $content = $content -join "`n" } + if ($content -notmatch "$MajorVersion") { continue } + } + + $line2 = git show -s --format='%cI%x09%s' $sTrim 2>$null + if (-not $line2) { continue } + $parts = $line2 -split "`t", 2 + if ($parts.Count -lt 2) { continue } + # Date is `git log --format=%cI` (committer date, ISO-8601 with + # 'Z' / offset). Route through ConvertTo-Utc so culture-sensitive + # [DateTime]::Parse doesn't silently shift the value on hosts + # whose locale doesn't accept ISO-8601 directly. + $dateUtc = ConvertTo-Utc -Value $parts[0] + if (-not $dateUtc) { continue } + return [PSCustomObject]@{ + Sha = $sTrim + Date = $dateUtc + Subject = $parts[1] + } + } + return $null + } catch { + return $null + } +} + +function New-ReadinessCheck { + <# + .SYNOPSIS + Constructs a readiness-check record used by the Blocking / Cleanup summaries + at the top of the markdown report. + .DESCRIPTION + Status semantics: + READY — check passed + WATCH — soft signal worth eyeballing; doesn't block ship + BLOCKED — must be resolved before ship; escalates verdict to Tier 1 (Not Ready) + CLEANUP — known follow-up that doesn't prevent ship (stale milestones, + bug-template entries that need to be added soon, etc.). Surfaces + in a dedicated "🧹 Cleanup follow-ups" section so it doesn't get + lost, but does NOT escalate the overall verdict. + UNKNOWN — check couldn't run (missing tool, no data); surfaces as ⚪ + #> + param( + [string]$Area, + [ValidateSet('READY', 'WATCH', 'BLOCKED', 'CLEANUP', 'UNKNOWN')][string]$Status, + [string]$Details, + [string]$NextAction + ) + [PSCustomObject]@{ + Area = $Area + Status = $Status + Details = $Details + NextAction = $NextAction + } +} + +function Get-ReleaseShipChecks { + <# + .SYNOPSIS + Runs the "ready to ship" checks for the SR/candidate report: + - Versions.props bumped to match the SR cycle (Major.Minor.Patch in [N0..N9]) + - Bug template's version-with-bug dropdown contains the expected SR version + + In CANDIDATE mode the checks still run, but the messaging notes that + the bumps + template updates happen AFTER the SR is cut, so a BLOCKED + status in candidate mode is a soft heads-up rather than a hard blocker + of the candidate itself. + .OUTPUTS + Array of check records (see New-ReadinessCheck). + #> + param($Ctx) + + $checks = @() + $isCandidate = ($Ctx.mode -eq 'candidate') + + # Determine the SR number from the SR branch name. In live-SR mode (not + # candidate), srBranch IS the release branch (release/X.Y.Zxx-srN). In + # candidate mode, srBranch is main and the prior-SR name lives in + # priorSrBranch — we want NEXT SR (= prior + 1). + $srBranchName = if ($isCandidate) { $Ctx.priorSrBranch } else { $Ctx.srBranch } + $srMatch = [regex]::Match($srBranchName, '^release/(\d+)\.(\d+)\.\d+xx-sr(\d+)$') + if (-not $srMatch.Success) { + $checks += New-ReadinessCheck -Area 'Versions.props bump' -Status 'UNKNOWN' ` + -Details "Could not parse SR number from '$srBranchName'." ` + -NextAction "Verify the branch matches release/X.Y.Zxx-srN." + return $checks + } + $major = [int]$srMatch.Groups[1].Value + $minor = [int]$srMatch.Groups[2].Value + $priorSr = [int]$srMatch.Groups[3].Value + $targetSr = if ($isCandidate) { $priorSr + 1 } else { $priorSr } + $expectedPatchPrefix = $targetSr * 10 # SR8 → 80, SR9 → 90, SR10 → 100 + + # Which ref do we read Versions.props from? + # Shipped mode: the SR branch itself. + # Candidate mode: main (which would carry the bump once SR-prior cuts). + $versionsRef = if ($isCandidate) { "origin/$($Ctx.mainBranch)" } else { $Ctx.srRef } + $vp = Get-VersionsPropsState -Ref $versionsRef + + if (-not $vp) { + $checks += New-ReadinessCheck -Area 'Versions.props bump' -Status 'UNKNOWN' ` + -Details "Could not read eng/Versions.props from ``$versionsRef``." ` + -NextAction "Inspect the file manually." + } else { + $patchInRange = ($vp.Patch -ge $expectedPatchPrefix -and $vp.Patch -lt ($expectedPatchPrefix + 10)) + $majorMinorMatch = ($vp.Major -eq $major -and $vp.Minor -eq $minor) + $area = if ($isCandidate) { "Versions.props bump (main → SR$targetSr)" } else { "Versions.props bump (SR$targetSr)" } + if ($majorMinorMatch -and $patchInRange) { + $checks += New-ReadinessCheck -Area $area -Status 'READY' ` + -Details "``$versionsRef`` reports ``$($vp.FullVersion)`` — within expected SR$targetSr range [$expectedPatchPrefix..$($expectedPatchPrefix + 9)]." ` + -NextAction "No bump needed." + } else { + $candidateHint = if ($isCandidate) { + " (Expected after SR$priorSr cut: bump main's PatchVersion from $($vp.Patch) to $expectedPatchPrefix.)" + } else { "" } + $checks += New-ReadinessCheck -Area $area -Status 'BLOCKED' ` + -Details "``$versionsRef`` reports ``$($vp.FullVersion)``; expected ``$major.$minor.[$expectedPatchPrefix..$($expectedPatchPrefix + 9)]`` for SR$targetSr.$candidateHint" ` + -NextAction "Bump eng/Versions.props (MajorVersion/MinorVersion/PatchVersion) before shipping SR$targetSr." + } + } + + # === Servicing-release flip === + # When an SR branch is cut from main, two eng/Versions.props values MUST be + # flipped to switch the branch from "CI build" mode to "stable release" mode: + # - PreReleaseVersionLabel: ci.main -> servicing + # - StabilizePackageVersion: false -> true (default value, may be unset) + # + # Without these flips, the SR branch still produces prerelease packages + # (`1.2.3-servicing-…` or `1.2.3-ci-…`) — never a stable `1.2.3` package. + # The build will succeed and CI will be green, so nothing else catches this: + # the only symptom is that the released NuGet packages never actually become + # stable. This is exactly the trap the check exists to slam shut. + # + # Skip in candidate mode — the candidate IS main, where these values are + # SUPPOSED to read ci.main / false. The flip happens AFTER the SR is cut. + if (-not $isCandidate -and $vp) { + $flipArea = "Versions.props servicing flip (SR$targetSr)" + $expectedLabel = 'servicing' + $expectedStabilize = 'true' + $actualLabel = if ($vp.PreReleaseVersionLabel) { $vp.PreReleaseVersionLabel } else { '' } + $actualStabilize = if ($vp.StabilizePackageVersion) { $vp.StabilizePackageVersion } else { '' } + $labelOk = ($vp.PreReleaseVersionLabel -eq $expectedLabel) + $stabilizeOk = ($vp.StabilizePackageVersion -eq $expectedStabilize) + if ($labelOk -and $stabilizeOk) { + # Provenance: was the flip done by an SR-direct commit, or just + # inherited from the previous SR via the catch-up merge? + # + # Walk NON-MERGE commits on this SR branch that aren't on the + # previous SR branch and aren't on main. Look for one that ADDED + # the `servicing` label line. If none → the flip is inherited via + # merge from the previous SR (functionally fine, the branch WILL + # produce stable packages, but worth surfacing so the release + # captain knows there was no deliberate SR-direct flip PR). + $prevSrBranch = "release/$major.$minor.1xx-sr$($targetSr - 1)" + $prevSrRef = "origin/$prevSrBranch" + $mainRef = if ($Ctx -is [hashtable]) { + if ($Ctx.ContainsKey('mainBranch')) { "origin/$($Ctx['mainBranch'])" } else { 'origin/main' } + } elseif ($Ctx.PSObject.Properties.Name -contains 'mainBranch') { + "origin/$($Ctx.mainBranch)" + } else { 'origin/main' } + $flipDirectSha = $null + try { + $shas = git log --no-merges --pretty='%H' "origin/$($Ctx.srBranch)" "^$prevSrRef" "^$mainRef" -- eng/Versions.props 2>$null + foreach ($s in @($shas)) { + $sTrim = $s.Trim(); if (-not $sTrim) { continue } + $diff = git show --no-color --format= $sTrim -- eng/Versions.props 2>$null + if ($diff -match '(?m)^\+\s*servicing') { + $flipDirectSha = $sTrim + break + } + } + } catch { } + + if ($flipDirectSha) { + $shortSha = $flipDirectSha.Substring(0, [Math]::Min(10, $flipDirectSha.Length)) + $details = "``$versionsRef`` has ``PreReleaseVersionLabel=servicing`` and ``StabilizePackageVersion=true`` (set by SR-direct commit ``$shortSha``) — branch is configured to produce stable release packages." + } else { + # Find the merge commit on this SR branch that brought in `prevSrBranch`. + $mergeShaShort = $null + try { + $mergeSha = git log --merges --pretty='%H' --first-parent "origin/$($Ctx.srBranch)" -- eng/Versions.props 2>$null | Select-Object -First 1 + if ($mergeSha) { $mergeShaShort = $mergeSha.Trim().Substring(0, 10) } + } catch { } + $provenance = if ($mergeShaShort) { + "inherited from ``$prevSrBranch`` via catch-up merge ``$mergeShaShort``" + } else { + "inherited from ``$prevSrBranch``" + } + $details = "``$versionsRef`` has ``PreReleaseVersionLabel=servicing`` and ``StabilizePackageVersion=true`` — branch IS configured to produce stable release packages, but the values were $provenance, not from an SR-direct flip PR (no commit on ``$($Ctx.srBranch)`` alone has set ``PreReleaseVersionLabel=servicing``). Functionally fine; surfaced so the release captain knows the workflow deviated from the previous SR's pattern (e.g., SR$($targetSr-1)'s explicit flip PR)." + } + + $checks += New-ReadinessCheck -Area $flipArea -Status 'READY' ` + -Details $details ` + -NextAction "No change needed." + } else { + $missing = @() + if (-not $labelOk) { $missing += "``PreReleaseVersionLabel=$actualLabel`` (expected ``servicing``)" } + if (-not $stabilizeOk) { $missing += "``StabilizePackageVersion=$actualStabilize`` (expected ``true``)" } + $checks += New-ReadinessCheck -Area $flipArea -Status 'BLOCKED' ` + -Details "``$versionsRef`` is NOT flipped to servicing-release mode: $($missing -join '; '). Without these flips the branch builds prerelease packages and will not ship as a stable .NET release — CI stays green so nothing else catches it." ` + -NextAction "Edit eng/Versions.props on ``$($Ctx.srBranch)``: set ``servicing`` and ``true``. See ``release/$major.$minor.1xx-sr$($targetSr - 1)`` for the canonical diff." + } + } + + # === Main bumped to NEXT SR cycle === + # Convention: as soon as a release/X.Y.Zxx-srN branch is cut, main MUST bump + # PatchVersion to (N+1)*10 so any new PRs landing on main during SR$N + # stabilization correctly target the next SR cycle, not the SR being shipped. + # + # If main is still at the same PatchVersion as the SR-to-ship, it's a hard + # ship-blocker: the moment SR$N tags, every "10.0.80" PR on main suddenly + # claims to be in a release that already shipped without it. + # + # Skip in candidate mode — there, main IS the surveyed ref and the check + # above already covers the same ground from the other direction. + if (-not $isCandidate) { + $mainRef = "origin/$($Ctx.mainBranch)" + $vpMain = Get-VersionsPropsState -Ref $mainRef + $nextSr = $targetSr + 1 + $expectedNextPatchPrefix = $nextSr * 10 + $mainArea = "Main bumped to SR$nextSr cycle" + + if (-not $vpMain) { + $checks += New-ReadinessCheck -Area $mainArea -Status 'UNKNOWN' ` + -Details "Could not read eng/Versions.props from ``$mainRef``." ` + -NextAction "Inspect the file manually." + } else { + # If main has moved to a newer major/minor (e.g. GA happened, main is + # on 11.0 while we ship 10.0 SR8), this check no longer applies — main + # is past this cycle entirely. + $mainPastMajor = ($vpMain.Major -gt $major) -or ` + ($vpMain.Major -eq $major -and $vpMain.Minor -gt $minor) + $mainBumpedThisCycle = ($vpMain.Major -eq $major -and $vpMain.Minor -eq $minor ` + -and $vpMain.Patch -ge $expectedNextPatchPrefix) + + if ($mainPastMajor) { + $checks += New-ReadinessCheck -Area $mainArea -Status 'READY' ` + -Details "``$mainRef`` reports ``$($vpMain.FullVersion)`` — main has moved past the $major.$minor train entirely (no bump needed for SR$targetSr stabilization)." ` + -NextAction "No bump needed." + } elseif ($mainBumpedThisCycle) { + $checks += New-ReadinessCheck -Area $mainArea -Status 'READY' ` + -Details "``$mainRef`` reports ``$($vpMain.FullVersion)`` — main is at or past ``$major.$minor.$expectedNextPatchPrefix`` so PRs merging during SR$targetSr stabilization target SR$nextSr correctly." ` + -NextAction "No bump needed." + } else { + $checks += New-ReadinessCheck -Area $mainArea -Status 'BLOCKED' ` + -Details "``$mainRef`` reports ``$($vpMain.FullVersion)`` — same cycle as the SR being shipped. Once SR$targetSr tags, every PR currently merging to main as ``$($vpMain.FullVersion)`` would falsely claim to ship in SR$targetSr." ` + -NextAction "Bump eng/Versions.props on main: set from $($vpMain.Patch) to $expectedNextPatchPrefix (SR$nextSr cycle) before shipping SR$targetSr." + } + } + } + + # === Bug template version listing === + # Issue templates live on the default branch (main) — they're global per repo. + $templateRef = "origin/$($Ctx.mainBranch)" + $templateVersions = Get-BugTemplateVersions -Ref $templateRef + # Acceptable: any entry matching $major.$minor., with or + # without an "SR$targetSr" or similar suffix. + $matchPattern = "^$major\.$minor\.(\d+)" + $matchingEntries = @($templateVersions | Where-Object { + if ($_ -match $matchPattern) { + $p = [int]$Matches[1] + return ($p -ge $expectedPatchPrefix -and $p -lt ($expectedPatchPrefix + 10)) + } + return $false + }) + + $bugArea = "Bug template lists SR$targetSr version" + if ($templateVersions.Count -eq 0) { + $checks += New-ReadinessCheck -Area $bugArea -Status 'UNKNOWN' ` + -Details "Could not read .github/ISSUE_TEMPLATE/bug-report.yml from ``$templateRef`` or the version-with-bug dropdown is empty." ` + -NextAction "Inspect the bug template manually." + } elseif ($matchingEntries.Count -gt 0) { + $first = $matchingEntries[0] + $checks += New-ReadinessCheck -Area $bugArea -Status 'READY' ` + -Details "Bug template lists ``$first`` (and $($matchingEntries.Count - 1) other SR$targetSr entries)." ` + -NextAction "No template update needed." + } else { + $sample = ($templateVersions | Select-Object -First 3) -join ', ' + # CLEANUP, not BLOCKED — missing the dropdown entry doesn't prevent the + # build from shipping; it just means the bug-report form won't list this + # version for the first few days. Surface prominently so it gets done, + # but don't escalate the verdict to Not Ready. + $checks += New-ReadinessCheck -Area $bugArea -Status 'CLEANUP' ` + -Details "No entry matching ``$major.$minor.[$expectedPatchPrefix..$($expectedPatchPrefix + 9)]`` found in version-with-bug dropdown on ``$templateRef``. Top entries: $sample." ` + -NextAction "Add the SR$targetSr version (e.g. ``$major.$minor.$expectedPatchPrefix``) to .github/ISSUE_TEMPLATE/bug-report.yml — can land before or shortly after ship." + } + + return $checks +} + +# region ──────────────── 0.5 MAESTRO / BAR OPERATIONAL CHECKS ─────────────── +# +# These check that the SR branch is wired into Build Asset Registry (BAR) so +# builds auto-flow to consumers. They require the `darc` CLI; in CI environments +# without darc they downgrade to UNKNOWN with verification commands, so the +# report never silently skips them — a release captain reading the issue still +# sees "BAR mapping: UNKNOWN — verify locally with: darc get-default-channels …" +# +# Real-world failure they catch: a new SR branch (e.g. release/10.0.1xx-sr8) is +# cut from main but nobody runs `darc add-default-channel`. CI builds succeed, +# but nothing flows to BAR, so at ship time there's no build to promote. The +# script would otherwise report all-green, hiding the problem. + +function Test-DarcAvailable { + <# + .SYNOPSIS + Cached probe for the `darc` CLI. Returns $true if `darc` is on PATH. + .NOTES + We deliberately use `Get-Command` instead of `darc --version`. darc itself + sets a non-zero exit code under certain conditions (auth-not-yet, telemetry + prompts) even when the executable is fully functional — so a `--version` + exit-code check produces false negatives on dev boxes. The downstream + Invoke-DarcJson wrapper handles real auth/network failures by surfacing + them as `Success = $false`, which the check renders as UNKNOWN. + #> + $cached = Get-Variable -Name '_darcAvailable' -Scope Script -ValueOnly -ErrorAction SilentlyContinue + if ($null -ne $cached) { return $cached } + $cmd = Get-Command darc -ErrorAction SilentlyContinue + $script:_darcAvailable = ($null -ne $cmd) + return $script:_darcAvailable +} + +function Invoke-DarcJson { + <# + .SYNOPSIS + Runs `darc --output-format json` and returns a result object that + unambiguously distinguishes failure from empty-but-successful responses. + .OUTPUTS + [PSCustomObject] with Success (bool) and Data (array, never $null when Success). + Returning a hashtable-style result avoids PowerShell's auto-unwrap of `@()` + across function boundaries, which would otherwise conflate "darc auth failed" + with "darc succeeded but returned no items". + #> + param([string[]]$DarcArgs) + try { + $jsonOutput = & darc @DarcArgs --output-format json 2>$null + if ($LASTEXITCODE -ne 0) { + return [PSCustomObject]@{ Success = $false; Data = @() } + } + $joined = ($jsonOutput | Out-String) + if ([string]::IsNullOrWhiteSpace($joined)) { + return [PSCustomObject]@{ Success = $true; Data = @() } + } + $parsed = $joined | ConvertFrom-Json -ErrorAction Stop + if ($null -eq $parsed) { + return [PSCustomObject]@{ Success = $true; Data = @() } + } + return [PSCustomObject]@{ Success = $true; Data = @($parsed) } + } catch { + return [PSCustomObject]@{ Success = $false; Data = @() } + } +} + +function Get-MaestroOperationalChecks { + <# + .SYNOPSIS + Runs Maestro/BAR operational checks for an in-flight SR branch: + 1. SR branch is in BAR default-channel mappings (so builds auto-flow) + 2. BAR has a build for SR HEAD commit (so promotion will have something) + + SKIPPED entirely (returns @()) when: + - $SkipChecks is set (caller opt-out) + - $Ctx.mode is 'candidate' (SR branch doesn't exist yet — false positive) + - SR branch name doesn't match release/X.Y.Zxx-srN (custom shapes, RC, etc.) + + When darc isn't available, emits UNKNOWN checks with verification commands + instead of silently skipping. This is intentional: the report should always + document what was NOT checked so the release captain can fill the gap. + .OUTPUTS + Array of New-ReadinessCheck records (merged into shipChecks downstream). + #> + param($Ctx, [switch]$SkipChecks) + + if ($SkipChecks) { return @() } + if ($Ctx.mode -eq 'candidate') { return @() } + + # Derive expected channel from SR branch shape. dotnet/maui convention: all + # SR branches in a major.minor cycle share ONE channel (no per-SR channel). + # Refusing to compute channel for non-SR shapes avoids posting incorrect + # add-default-channel commands. + $branchMatch = [regex]::Match($Ctx.srBranch, '^release/(\d+\.\d+\.\d+xx)-sr\d+$') + if (-not $branchMatch.Success) { return @() } + $sdkBand = $branchMatch.Groups[1].Value + $expectedChannel = ".NET $sdkBand SDK" + $repoUrl = "https://github.com/$($Ctx.repo)" + + $checks = @() + $darcReady = Test-DarcAvailable + + # === Check 1: SR branch wired into BAR default-channel mappings === + # The critical check. If missing, no SR builds reach BAR — release captain + # has nothing to promote at ship time. + $mappingArea = "BAR default-channel mapping ($($Ctx.srBranch) → $expectedChannel)" + if (-not $darcReady) { + $checks += New-ReadinessCheck -Area $mappingArea -Status 'UNKNOWN' ` + -Details "``darc`` CLI not available in this environment — cannot query BAR. Verify manually." ` + -NextAction "Locally: ``darc get-default-channels --source-repo $repoUrl`` and search for ``$($Ctx.srBranch)``. If missing, escalate to release engineering: ``darc add-default-channel --channel ""$expectedChannel"" --branch $($Ctx.srBranch) --repo $repoUrl``" + } else { + $defaultChannels = Invoke-DarcJson -DarcArgs @('get-default-channels', '--source-repo', $repoUrl) + if (-not $defaultChannels.Success) { + $checks += New-ReadinessCheck -Area $mappingArea -Status 'UNKNOWN' ` + -Details "``darc get-default-channels --source-repo $repoUrl`` failed (likely auth, network, or BAR outage)." ` + -NextAction "Run locally and inspect: ``darc get-default-channels --source-repo $repoUrl``" + } else { + $srMapping = @($defaultChannels.Data | Where-Object { + $_.branch -eq $Ctx.srBranch -and $_.enabled + }) + if ($srMapping.Count -gt 0) { + $m = $srMapping[0] + $checks += New-ReadinessCheck -Area $mappingArea -Status 'READY' ` + -Details "``$($Ctx.srBranch)`` is wired to channel **$($m.channel.name)** (BAR mapping id $($m.id))." ` + -NextAction "No action needed." + } else { + $checks += New-ReadinessCheck -Area $mappingArea -Status 'BLOCKED' ` + -Details "``$($Ctx.srBranch)`` has NO default-channel mapping in BAR. CI builds on this branch are NOT auto-flowing to **$expectedChannel** — the release captain will have no build to promote when shipping." ` + -NextAction "Escalate to release engineering: ``darc add-default-channel --channel ""$expectedChannel"" --branch $($Ctx.srBranch) --repo $repoUrl`` (do NOT run unprompted — requires release-eng approval)." + } + } + } + + # === Check 2: BAR has a build for SR HEAD commit === + # Secondary signal. If mapping is OK but no build for HEAD: CI is still + # running OR something blocked publishing. WATCH (not BLOCKED) because + # transient — re-running the report tomorrow will resolve it. + if (-not $Ctx.srHeadSha) { return $checks } + $headShort = $Ctx.srHeadSha.Substring(0, 8) + $buildArea = "BAR build for SR HEAD ($headShort)" + if (-not $darcReady) { + $checks += New-ReadinessCheck -Area $buildArea -Status 'UNKNOWN' ` + -Details "``darc`` CLI not available — cannot verify BAR has a build for SR HEAD." ` + -NextAction "Locally: ``darc get-build --repo $repoUrl --commit $($Ctx.srHeadSha)``" + } else { + $builds = Invoke-DarcJson -DarcArgs @('get-build', '--repo', $repoUrl, '--commit', $Ctx.srHeadSha) + if (-not $builds.Success) { + $checks += New-ReadinessCheck -Area $buildArea -Status 'UNKNOWN' ` + -Details "``darc get-build`` failed for SR HEAD ``$headShort``." ` + -NextAction "Run locally: ``darc get-build --repo $repoUrl --commit $($Ctx.srHeadSha)``" + } elseif ($builds.Data.Count -eq 0) { + $checks += New-ReadinessCheck -Area $buildArea -Status 'WATCH' ` + -Details "No BAR build found for SR HEAD ``$headShort``. May be normal if CI is still running, OR a symptom of the default-channel mapping being absent (see prior check)." ` + -NextAction "Wait for CI to complete on SR HEAD; re-run readiness report. If mapping is also missing (above), fix that first." + } else { + # Sort by BAR build id (monotonic, locale-independent) to pick the latest. + $latest = @($builds.Data | Sort-Object id -Descending)[0] + $chans = if ($latest.channels) { ($latest.channels -join ', ') } else { '_none_' } + $buildLink = if ($latest.buildLink) { " ([build $($latest.id)]($($latest.buildLink)))" } else { " (build $($latest.id))" } + $checks += New-ReadinessCheck -Area $buildArea -Status 'READY' ` + -Details "Build **$($latest.buildNumber)**$buildLink for SR HEAD ``$headShort`` is in BAR; channels: $chans." ` + -NextAction "No action needed." + } + } + + return $checks +} + +# endregion + +# region ───────────────── 0.6 MILESTONE HYGIENE CHECKS ─────────────────────── +# +# Ship-readiness checks against the GitHub milestone list: +# 1. Current cycle's milestone exists (e.g. ".NET 10 SR8" must exist if +# we're shipping SR8). Without it, fixed issues have no milestone to land on. +# 2. Next cycle's milestone exists (e.g. ".NET 10 SR9" or ".NET 11.0-preview6"). +# Without it, unfinished work has nowhere to roll forward when current ships. +# 3. Stale open milestones with past due_on are flagged. After a release ships, +# its milestone should be closed; lingering open milestones are release hygiene +# gaps that accumulate misfiled issues and confuse triage. +# +# These checks are evidence-backed and queried via `gh api repos/.../milestones` — +# no auth issues in normal CI; the GH MCP/CI environments always have a token. + +function Get-AllMilestones { + <# + .SYNOPSIS + Fetches all milestones (open + closed) for a repo via `gh api`. Returns + a Success/Data envelope (same pattern as Invoke-DarcJson) so callers can + distinguish "API call failed" from "no milestones exist". + .NOTES + Query parameters MUST be embedded in the URL — passing them via `-f` + switches `gh api` to POST mode (treats them as form body), which the + milestones endpoint rejects with HTTP 422. + #> + param([string]$Repo) + try { + $raw = Invoke-Gh @('api', "repos/$Repo/milestones?state=all&per_page=100", '--paginate') + # A successful milestones query always returns at least `[]`. Empty/null + # output means Invoke-Gh swallowed a non-zero gh exit (auth/network), so + # surface it as a failure rather than masking it as "zero milestones" + # (which would let milestone-hygiene checks silently pass). + if (-not $raw) { return [PSCustomObject]@{ Success = $false; Data = @() } } + $parsed = $raw | ConvertFrom-Json + return [PSCustomObject]@{ Success = $true; Data = @($parsed) } + } catch { + return [PSCustomObject]@{ Success = $false; Data = @() } + } +} + +function Get-MilestoneHygieneChecks { + <# + .SYNOPSIS + Runs three milestone-related ship-readiness checks against the repo's + GitHub milestone list. SKIPPED entirely (returns @()) when: + - $SkipChecks is set + - Branch shape doesn't match an SR or preview release naming convention + (custom shapes / RC / hotfix branches — can't reliably derive the + expected milestone title). + Returns BLOCKED checks when: + - Current cycle's milestone is missing + - Next cycle's milestone is missing + - There are open milestones with past-due due_on dates (excluding the + current cycle and long-running organizational milestones like Backlog + and ".NET Planning"). + .OUTPUTS + Array of New-ReadinessCheck records (merged into shipChecks downstream). + #> + param($Ctx, [switch]$SkipChecks) + + if ($SkipChecks) { return @() } + + # In candidate mode we're surveying main as the next SR/preview. The cycle + # we're prepping is the prior branch's cycle number + 1. In in-flight mode + # we use srBranch directly. + $branchToParse = if ($Ctx.mode -eq 'candidate') { $Ctx.priorSrBranch } else { $Ctx.srBranch } + if (-not $branchToParse) { return @() } + + # Parse SR shape first (release/10.0.1xx-sr8), then preview (release/11.0.1xx-preview5). + $srMatch = [regex]::Match($branchToParse, '^release/(\d+)\.0\.\d+xx-sr(\d+)$') + $previewMatch = [regex]::Match($branchToParse, '^release/(\d+)\.0\.\d+xx-preview(\d+)$') + + $expectedTitlesCurrent = @() + $expectedTitlesNext = @() + $cycleLabel = '' + + if ($srMatch.Success) { + $major = [int]$srMatch.Groups[1].Value + $cycleNum = [int]$srMatch.Groups[2].Value + if ($Ctx.mode -eq 'candidate') { $cycleNum++ } + # MAUI uses both legacy ".NET X.0 SRn" and current ".NET X SRn" forms; + # treat either as satisfying the check so we don't trigger false BLOCKED + # on historical milestones. + $expectedTitlesCurrent = @(".NET $major SR$cycleNum", ".NET $major.0 SR$cycleNum") + $expectedTitlesNext = @(".NET $major SR$($cycleNum + 1)", ".NET $major.0 SR$($cycleNum + 1)") + $cycleLabel = "SR$cycleNum" + } elseif ($previewMatch.Success) { + $major = [int]$previewMatch.Groups[1].Value + $cycleNum = [int]$previewMatch.Groups[2].Value + if ($Ctx.mode -eq 'candidate') { $cycleNum++ } + $expectedTitlesCurrent = @(".NET $major.0-preview$cycleNum") + $expectedTitlesNext = @(".NET $major.0-preview$($cycleNum + 1)") + $cycleLabel = "preview$cycleNum" + } else { + # Unknown branch shape — can't derive milestone names. Skip silently. + return @() + } + + $milestonesResult = Get-AllMilestones -Repo $Ctx.repo + if (-not $milestonesResult.Success) { + return @(New-ReadinessCheck -Area "Milestone hygiene" -Status 'UNKNOWN' ` + -Details "Failed to query milestones from GitHub API for ``$($Ctx.repo)``." ` + -NextAction "Re-run with valid 'gh' auth: ``gh auth status`` and ``gh api repos/$($Ctx.repo)/milestones``") + } + + $allMs = $milestonesResult.Data + $checks = @() + + # === Check 1: Current cycle's milestone exists === + $currentMs = @($allMs | Where-Object { $expectedTitlesCurrent -contains $_.title }) + $currentTitle = $expectedTitlesCurrent[0] + if ($currentMs.Count -eq 0) { + $checks += New-ReadinessCheck -Area "Milestone for current cycle ($currentTitle)" -Status 'BLOCKED' ` + -Details "No milestone matching ``$currentTitle`` exists in ``$($Ctx.repo)``. Fixed issues from this cycle have no milestone to land on, and the release notes generator will have nothing to query." ` + -NextAction "Create the milestone: ``gh api repos/$($Ctx.repo)/milestones -f title=""$currentTitle"" -f state=open``" + } + + # === Check 2: Next cycle's milestone exists === + # Surfaced as CLEANUP (not BLOCKED) — a missing roll-forward milestone is a + # follow-up concern, not a ship blocker. The current cycle can still ship + # while the next milestone hasn't been created yet; release captain can + # create it any time before the next cycle starts. In candidate mode this + # is especially conservative: SR9 candidate would otherwise BLOCK on + # missing SR10, even though we're not yet ready to cut SR9. + $nextMs = @($allMs | Where-Object { $expectedTitlesNext -contains $_.title }) + $nextTitle = $expectedTitlesNext[0] + if ($nextMs.Count -eq 0) { + $checks += New-ReadinessCheck -Area "Milestone for next cycle ($nextTitle)" -Status 'CLEANUP' ` + -Details "No milestone matching ``$nextTitle`` exists. Once ``$cycleLabel`` ships, open issues will have nowhere to roll forward to — but ``$cycleLabel`` can ship first." ` + -NextAction "Create the milestone before the next cycle begins: ``gh api repos/$($Ctx.repo)/milestones -f title=""$nextTitle"" -f state=open``" + } + + # === Check 3: Stale open milestones with past due_on === + # Filtered by cycle to avoid cross-train noise: when surveying an SR cycle, + # flag only stale `.NET SR*` milestones; when surveying a preview + # cycle, flag only stale `.NET .0-preview*`. A 7-day grace period + # after due_on lets the actively-shipping release still appear open without + # triggering BLOCKED. + # Also excluded: + # - the current cycle (still being prepped) + # - "Backlog" (intentional long-running) + # - ".NET N Planning" (intentional long-running planning ms) + # - milestones without due_on (caller has no schedule, no signal) + $now = (Get-Date).ToUniversalTime() + $graceCutoff = $now.AddDays(-7) + $cycleFilter = if ($srMatch.Success) { + # Match ".NET SR" and ".NET .0 SR" (and SR.) + "^\.NET\s+$major(\.0)?\s+SR\d+(\.\d+)?$" + } else { + # Match ".NET .0-preview" + "^\.NET\s+$major\.0-preview\d+$" + } + $staleMs = @($allMs | Where-Object { + $_.state -eq 'open' -and + $_.due_on -and + ([datetime]$_.due_on).ToUniversalTime() -lt $graceCutoff -and + ($expectedTitlesCurrent -notcontains $_.title) -and + ($_.title -match $cycleFilter) + } | Sort-Object { [datetime]$_.due_on }) + + if ($staleMs.Count -gt 0) { + $list = ($staleMs | ForEach-Object { + $dueDate = ([datetime]$_.due_on).ToUniversalTime().ToString('yyyy-MM-dd') + "[$($_.title)](https://github.com/$($Ctx.repo)/milestone/$($_.number)) (due $dueDate, $($_.open_issues) open)" + }) -join '; ' + # CLEANUP, not BLOCKED — stale milestones from already-shipped releases are + # a housekeeping debt (issues need to be rolled forward / closed-as-fixed), + # but they don't prevent THIS release from shipping. Surface prominently so + # it gets triaged, but don't escalate the verdict to Not Ready. + $checks += New-ReadinessCheck -Area "Stale open milestones ($($staleMs.Count))" -Status 'CLEANUP' ` + -Details "$($staleMs.Count) milestone(s) in the .NET $major cycle are past due (>7 days) and still open: $list. These represent already-shipped releases that were never closed out — accumulating open issues that should have been rolled forward." ` + -NextAction "For each: triage the open issues (close-as-fixed, move to current cycle, or move to Backlog), then close the milestone: ``gh api -X PATCH repos/$($Ctx.repo)/milestones/ -f state=closed``" + } + + return $checks +} + +function Get-CandidatePrChecks { + <# + .SYNOPSIS + Builds a ship-readiness check for the open "Candidate" PR — the PR + that promotes a specific main commit as the basis for cutting the + next SR. Only meaningful in candidate mode: once the SR branch is + actually cut we switch to in-flight mode and there's no longer a + "next SR cut" to track. + .DESCRIPTION + Convention: the Candidate PR has "Candidate" in the title (word + boundary, case-insensitive — e.g. "June 8th, Candidate") AND is + opened by a maintainer (OWNER/MEMBER/COLLABORATOR). The + authorAssociation gate prevents an unrelated community PR titled + "Candidate ..." from spoofing the cut PR. It's normally opened + against ``main`` (not the SR branch), so we scan ALL open PRs on + main, not just $openSrPrs. + + Status semantics: + - in-flight mode → returns @() (no check; SR is already cut) + - candidate mode, candidate PR open → WATCH (must land before cut) + - candidate mode, no candidate PR found → WATCH (informational) + - candidate mode, gh query failed → WATCH (missing signal) + + Never BLOCKED: a missing candidate PR is normal early in the + cycle. The release captain decides when to open one. + .OUTPUTS + Array of New-ReadinessCheck records (merged into shipChecks downstream). + #> + param($Ctx, [switch]$SkipChecks) + + if ($SkipChecks) { return ,@() } + if ($Ctx.mode -ne 'candidate') { return ,@() } + + $repoUrl = "https://github.com/$($Ctx.repo)" + + # Compute next SR label from priorSrBranch (set by Resolve-Context in + # candidate mode). priorSrBranch = e.g. 'release/10.0.1xx-sr8' → next is SR9. + $nextSr = $null + if ($Ctx.priorSrBranch -and $Ctx.priorSrBranch -match 'sr(\d+)$') { + $nextSr = "SR$([int]$Matches[1] + 1)" + } + + $area = if ($nextSr) { + "Candidate PR for next SR cut ($nextSr)" + } else { + "Candidate PR for next SR cut" + } + + # Scan open PRs targeting main (the Candidate PR is opened on main, not + # on the SR branch, since the SR branch may not exist yet in candidate + # mode). Cheap: one gh call returning up to 100 open PRs on main. + # Include authorAssociation in the json projection so we can gate on + # OWNER/MEMBER/COLLABORATOR — without this, ANY open PR with + # "Candidate" in its title would spoof the cut PR. + $raw = Invoke-Gh @('pr', 'list', '--repo', $Ctx.repo, '--state', 'open', + '--base', $Ctx.mainBranch, '--limit', '100', + '--json', 'number,title,author,authorAssociation,updatedAt,url') + if ($null -eq $raw) { + # gh failed — distinguish from "no Candidate PR found" so the + # verdict doesn't silently READY on tool failure. + return ,@(New-ReadinessCheck -Area $area -Status 'WATCH' ` + -Details "Could not query open PRs on ``$($Ctx.mainBranch)`` (``gh pr list`` exited non-zero). Cut readiness cannot be evaluated until the query succeeds." ` + -NextAction "Verify ``gh auth status`` and rerun. If gh is unavailable in this environment, check the Candidate PR manually.") + } + $mainPrs = @() + $parsed = $raw | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($parsed) { $mainPrs = @($parsed) } + + # Word-boundary match so "CandidateView" doesn't spoof. + $titleMatches = @($mainPrs | Where-Object { $_.title -match '(?i)\bcandidate\b' }) + + # Author gating: only PRs from a maintainer count. Outside contributors + # never open SR-cut PRs by convention. GraphQL returns authorAssociation + # as the enum 'OWNER' | 'MEMBER' | 'COLLABORATOR' | 'CONTRIBUTOR' | etc. + $maintainerAssociations = @('OWNER', 'MEMBER', 'COLLABORATOR') + $candidates = @($titleMatches | Where-Object { + $assoc = if ($_.PSObject.Properties['authorAssociation']) { $_.authorAssociation } else { $null } + $assoc -and ($maintainerAssociations -contains $assoc) + }) + $rejectedBySpoofGate = $titleMatches.Count - $candidates.Count + + if ($candidates.Count -eq 0) { + $rejectNote = if ($rejectedBySpoofGate -gt 0) { + " ($rejectedBySpoofGate non-maintainer PR(s) titled 'Candidate' were excluded as not real cut PRs)" + } else { '' } + return ,@(New-ReadinessCheck -Area $area -Status 'WATCH' ` + -Details "No open PR matching ``*Candidate*`` from a maintainer (OWNER/MEMBER/COLLABORATOR) found on ``$($Ctx.mainBranch)``$rejectNote. The Candidate PR is the mechanism that promotes a specific main commit as the SR cut point." ` + -NextAction "When ready to cut, open a Candidate PR against ``$($Ctx.mainBranch)`` selecting the target main commit for the next SR.") + } + + # Build a compact detail string listing all open candidate PRs (almost + # always 1, but if multiple are open the release captain should pick). + $links = ($candidates | ForEach-Object { + $titleShort = if ($_.title.Length -gt 60) { $_.title.Substring(0, 60) + '...' } else { $_.title } + "[#$($_.number)]($repoUrl/pull/$($_.number)) — $titleShort" + }) -join '; ' + + return ,@(New-ReadinessCheck -Area $area -Status 'WATCH' ` + -Details "$($candidates.Count) open Candidate PR(s) on ``$($Ctx.mainBranch)``: $links. This PR promotes a specific main commit as the SR cut point — it must be merged (and the SR branch cut from it) before the SR cycle starts." ` + -NextAction "Review and merge the Candidate PR when ready; the SR cut follows from its merge commit.") +} + +# endregion + +# region ────────────────────── 1. CONTEXT RESOLUTION ────────────────────── + +function Resolve-Context { + param([string]$SrBranch, [string]$Repo, [string]$MainBranch, + [string[]]$ExcludeBranches, [switch]$NoFetch, [switch]$Candidate, + [switch]$InheritFromPriorSr) + + if ($InheritFromPriorSr -and -not $Candidate) { + throw "-InheritFromPriorSr is only valid with -Candidate (it models the SR cut-then-merge workflow)." + } + + # HARD VALIDATION — refuse inflight/staging refs as SR sources. + # See $Script:ForbiddenSrPatterns at top of file for the rule rationale. + foreach ($pat in $Script:ForbiddenSrPatterns) { + if ($SrBranch -match $pat) { + throw "REFUSED: '$SrBranch' is not a valid SR branch — SR branches in dotnet/maui cut from `main`, never from inflight/staging/backport refs. Use a `release/X.Y.Zxx-srN` branch, or pass -Candidate to pre-flight `main`." + } + } + foreach ($eb in $ExcludeBranches) { + $stripped = $eb -replace '^origin/', '' + foreach ($pat in $Script:ForbiddenSrPatterns) { + if ($stripped -match $pat) { + Write-Warn "Exclude branch '$eb' is an inflight/staging ref — dropping. SR contents should only be compared against main or another SR branch." + $ExcludeBranches = $ExcludeBranches | Where-Object { $_ -ne $eb } + } + } + } + + if (-not $NoFetch) { + Write-Host "Fetching latest refs..." -ForegroundColor Cyan + & git fetch --all --quiet 2>$null | Out-Null + } + + # Candidate mode: swap roles — main becomes the "SR-to-be", named SrBranch + # becomes the exclude baseline (prior SR). This lets us answer "what would + # SRn+1 contain if cut today?" without requiring the branch to exist yet. + # Two modes encoded in the surveyed context: + # - 'in-flight' (default): -SrBranch points at an existing release/*-srN branch. + # We're surveying its current state for ship-readiness. + # - 'candidate': -Candidate is set, so the named SrBranch is actually the + # PRIOR SR (used as exclude baseline) and we're simulating "what would the + # NEXT SR contain if cut off main today?". Compatible legacy alias: 'shipped'. + $mode = 'in-flight' + $effectiveSrRef = "origin/$SrBranch" + $effectiveExcludes = $ExcludeBranches + + if ($Candidate) { + $mode = 'candidate' + $priorSrRef = "origin/$SrBranch" + $priorSrSha = Invoke-Git "rev-parse $priorSrRef" + if (-not $priorSrSha) { + throw "Candidate mode requires -SrBranch to be the prior SR (used as exclude baseline). '$priorSrRef' not found." + } + $effectiveSrRef = "origin/$MainBranch" + # Exclude prior SR from main, so we see only "new since last SR" commits + $effectiveExcludes = @($priorSrRef) + Write-Host "Candidate mode: surveying $effectiveSrRef vs prior SR $priorSrRef" -ForegroundColor Cyan + } + + if ($Candidate -and $InheritFromPriorSr) { + Write-Host " -InheritFromPriorSr active: SR-to-be contents will be augmented with $priorSrRef-only commits" -ForegroundColor Cyan + } + + $srHead = Invoke-Git "rev-parse $effectiveSrRef" + if (-not $srHead) { + throw "Branch '$effectiveSrRef' not found. Did you push it? (try without -NoFetch)" + } + $srSubject = Invoke-Git "log -1 --format=%s $effectiveSrRef" + + $mainHead = Invoke-Git "rev-parse origin/$MainBranch" + if (-not $mainHead) { Write-Warn "Main branch 'origin/$MainBranch' not found" } + + # Validate exclude branches exist; drop missing with warning + $validExcludes = @() + foreach ($b in $effectiveExcludes) { + $sha = Invoke-Git "rev-parse $b" + if ($sha) { + $validExcludes += $b + } else { + Write-Warn "Exclude branch '$b' not found, dropping" + } + } + + @{ + repo = $Repo + srBranch = if ($Candidate) { $MainBranch } else { $SrBranch } + srRef = $effectiveSrRef + srHeadSha = $srHead + srHeadSubject = $srSubject + mainBranch = $MainBranch + mainHeadSha = $mainHead + excludeBranches = $validExcludes + mode = $mode + priorSrBranch = if ($Candidate) { $SrBranch } else { $null } + priorSrRef = if ($Candidate) { "origin/$SrBranch" } else { $null } + inheritFromPriorSr = [bool]($Candidate -and $InheritFromPriorSr) + fetchedAt = (Get-Date).ToUniversalTime().ToString('o') + } +} + +# region ────────────────────── 2. SR COMMITS + SOURCE PR EXTRACTION ─────── + +function Get-RevertedPrFromSubject { + <# + .SYNOPSIS + Extracts the ORIGINAL (reverted) PR number from a revert commit subject. + Returns $null when the subject carries no reverted-PR reference. + .NOTES + GitHub's revert button produces: Revert "Original title (#1234)" (#5678) + The reverted PR is 1234 (inside the quoted original title). The trailing + (#5678) is the revert PR's OWN number and must NOT be returned. + + A previous greedy pattern — Revert.*\(#(\d+)\) — captured the LAST (#N), + i.e. 5678, into $revertsPr. Because that value was truthy, the authoritative + SHA-lookup fallback was skipped and the real reverted PR (1234) never landed + in the reverted set, flipping a reverted regression fix to 'in-sr-active' + (a false-green "ready to ship" verdict for a release whose fix was backed out). + #> + param([string]$Subject) + if (-not $Subject) { return $null } + # Explicit "Revert PR #NNNN" form. + $m = [regex]::Match($Subject, '(?i)Revert\s+PR\s+#(\d+)') + if ($m.Success) { return [int]$m.Groups[1].Value } + # Standard GitHub revert: the (#N) INSIDE the quoted original title, e.g. + # Revert "Original title (#1234)" (#5678). Greedy .* anchored to the closing + # quote captures the original PR (1234): it tolerates internal quotes in the + # title (the old [^"]* halted at the first inner quote and returned null) and, + # because the trailing revert PR is NOT followed by a quote, never reaches it. + # Case-insensitive to also match hand-typed lowercase 'revert "..."' subjects. + $m = [regex]::Match($Subject, '(?i)Revert\s+".*\(#(\d+)\)"') + if ($m.Success) { return [int]$m.Groups[1].Value } + return $null +} + +# Internal scanner — extracts source PRs / backports / reverts from commits +# selected by an arbitrary `git log` rev-spec. Used by Get-SrCommits both for +# the primary scan and (optionally) for the inherited-from-prior-SR scan. +function Get-CommitsForRevSpec { + param( + [string]$RevSpec, # e.g. "origin/main ^origin/release/10.0.1xx-sr7" + [string]$OriginTag = 'primary' + ) + + $shaList = Invoke-Git "log --format=%H $RevSpec" + if (-not $shaList) { + return @{ + commits = @(); sourcePrs = @(); backportPrs = @(); + reverts = @(); fixedIssues = @() + } + } + $shas = @($shaList) + + $commits = @() + $allSourcePrs = New-Object 'System.Collections.Generic.HashSet[int]' + $allBackportPrs = New-Object 'System.Collections.Generic.HashSet[int]' + $reverts = @() + $fixedIssues = New-Object 'System.Collections.Generic.HashSet[int]' + + foreach ($sha in $shas) { + $raw = Invoke-Git "show --no-patch --format=%H%n%an%n%aI%n%s%n--BODY-START--%n%b $sha" + if (-not $raw) { continue } + $lines = @($raw) + $cmtSha = $lines[0] + $author = $lines[1] + $authorDate = $lines[2] + $subject = $lines[3] + $bodyStartIdx = [Array]::IndexOf($lines, '--BODY-START--') + $body = if ($bodyStartIdx -ge 0 -and $bodyStartIdx -lt $lines.Count - 1) { + ($lines[($bodyStartIdx + 1)..($lines.Count - 1)] -join "`n") + } else { '' } + + # Backport PR: last "(#NNNN)" in subject + $backportPr = $null + $subjMatches = [regex]::Matches($subject, '\(#(\d+)\)') + if ($subjMatches.Count -gt 0) { + $backportPr = [int]$subjMatches[$subjMatches.Count - 1].Groups[1].Value + $allBackportPrs.Add($backportPr) | Out-Null + $allSourcePrs.Add($backportPr) | Out-Null # greedy: backport # also resolves + } + + # Source PR strong signal: "Backport of #NNNN" / "cherry picked from PR #NNNN" + $sourcePr = $null + $sourceMatch = [regex]::Match($body, '(?im)(?:backport\s+of|cherry[-\s]picked\s+from(?:\s+PR)?)\s+#(\d+)') + if ($sourceMatch.Success) { + $sourcePr = [int]$sourceMatch.Groups[1].Value + $allSourcePrs.Add($sourcePr) | Out-Null + } + + # cherry-pick source SHA: "(cherry picked from commit )" + $cherrySourceSha = $null + $cherryShaMatch = [regex]::Match($body, '(?im)cherry\s+picked\s+from\s+commit\s+([0-9a-f]{7,40})') + if ($cherryShaMatch.Success) { $cherrySourceSha = $cherryShaMatch.Groups[1].Value } + + # Fixed issues + $issMatches = [regex]::Matches($body, '(?im)(?:fixes|closes|resolves)\s+(?:dotnet/maui#|#)(\d+)') + $fixesList = @() + foreach ($m in $issMatches) { + $n = [int]$m.Groups[1].Value + $fixesList += $n + $fixedIssues.Add($n) | Out-Null + } + + # Revert detection — matches "Revert ", "[Revert]", or "[branch-prefix] Revert ..." + $isRevert = ($subject -match '(?i)^(?:\[[^\]]+\]\s+)?Revert\b') -or ($subject -match '\[Revert\]') + $revertsCommit = $null + $revertsPr = $null + if ($isRevert) { + $revM = [regex]::Match($body, '(?im)This reverts commit\s+([0-9a-f]{7,40})') + if ($revM.Success) { $revertsCommit = $revM.Groups[1].Value } + + # Recover the ORIGINAL (reverted) PR number from the subject. See + # Get-RevertedPrFromSubject for why the trailing (#N) on a revert + # subject is the revert's OWN PR and must not be used here. + $revertsPr = Get-RevertedPrFromSubject -Subject $subject + + # Authoritative override: when we know the reverted commit SHA, read its + # real subject — its trailing (#NNNN) IS the reverted PR's own number. + # This is ground truth and overrides any subject-based guess above. + if ($revertsCommit) { + $revSubj = Invoke-Git "log -1 --format=%s $revertsCommit" + if ($revSubj) { + $rsM = [regex]::Matches($revSubj, '\(#(\d+)\)') + if ($rsM.Count -gt 0) { + $revertsPr = [int]$rsM[$rsM.Count - 1].Groups[1].Value + } + } + } + $reverts += @{ + revertCommit = $cmtSha + revertsCommit = $revertsCommit + revertsPr = $revertsPr + revertBackportPr = $backportPr + origin = $OriginTag + } + } + + $commits += @{ + sha = $cmtSha + author = $author + date = $authorDate + subject = $subject + isRevert = $isRevert + backportPr = $backportPr + sourcePr = $sourcePr + cherrySourceSha = $cherrySourceSha + fixedIssues = $fixesList + origin = $OriginTag + } + } + + @{ + commits = $commits + sourcePrs = @($allSourcePrs) + backportPrs = @($allBackportPrs) + reverts = $reverts + fixedIssues = @($fixedIssues) + } +} + +function Get-SrCommits { + param($Ctx) + + Write-Host "Computing SR-only commits..." -ForegroundColor Cyan + $excludeArgs = $Ctx.excludeBranches | ForEach-Object { "^$_" } + $primaryRevSpec = "$($Ctx.srRef) $($excludeArgs -join ' ')" + $primary = Get-CommitsForRevSpec -RevSpec $primaryRevSpec -OriginTag 'primary' + Write-Host " Found $($primary.commits.Count) primary SR commits" -ForegroundColor Gray + + $inherited = $null + if ($Ctx.inheritFromPriorSr -and $Ctx.priorSrRef) { + # Inheritance set: commits on prior SR that are NOT yet on main. + # When the SR-to-be (main today) has the prior SR merged in, these are + # the additional shipping commits. + Write-Host "Computing prior-SR-only commits ($($Ctx.priorSrRef) not in $($Ctx.srRef))..." -ForegroundColor Cyan + $inheritRevSpec = "$($Ctx.priorSrRef) ^$($Ctx.srRef)" + $inherited = Get-CommitsForRevSpec -RevSpec $inheritRevSpec -OriginTag 'inherited' + Write-Host " Found $($inherited.commits.Count) inherited-from-prior-SR commits" -ForegroundColor Gray + } + + # Merge primary + inherited into a single SR-contents view. + # We keep an `origin` tag on each item so the report can disambiguate. + $mergedCommits = @($primary.commits) + $mergedReverts = @($primary.reverts) + $sourcePrSet = New-Object 'System.Collections.Generic.HashSet[int]' + foreach ($n in $primary.sourcePrs) { $sourcePrSet.Add([int]$n) | Out-Null } + $backportPrSet = New-Object 'System.Collections.Generic.HashSet[int]' + foreach ($n in $primary.backportPrs) { $backportPrSet.Add([int]$n) | Out-Null } + $fixedIssueSet = New-Object 'System.Collections.Generic.HashSet[int]' + foreach ($n in $primary.fixedIssues) { $fixedIssueSet.Add([int]$n) | Out-Null } + + if ($inherited) { + $mergedCommits += $inherited.commits + $mergedReverts += $inherited.reverts + foreach ($n in $inherited.sourcePrs) { $sourcePrSet.Add([int]$n) | Out-Null } + foreach ($n in $inherited.backportPrs) { $backportPrSet.Add([int]$n) | Out-Null } + foreach ($n in $inherited.fixedIssues) { $fixedIssueSet.Add([int]$n) | Out-Null } + } + + $srcPrsSorted = @($sourcePrSet | Sort-Object) + $result = @{ + commitCount = $mergedCommits.Count + primaryCommitCount = $primary.commits.Count + inheritedCommitCount = if ($inherited) { $inherited.commits.Count } else { 0 } + commits = $mergedCommits + sourcePrs = $srcPrsSorted + sourcePrCount = $srcPrsSorted.Count + primarySourcePrs = @($primary.sourcePrs | Sort-Object) + inheritedSourcePrs = if ($inherited) { @($inherited.sourcePrs | Sort-Object) } else { @() } + backportPrs = @($backportPrSet | Sort-Object) + fixedIssues = @($fixedIssueSet | Sort-Object) + reverts = $mergedReverts + } + return $result +} + +# region ────────────────────── 3. CI STATUS ─────────────────────────────── + +# Safe property accessor for AzDO API responses under Set-StrictMode -Version Latest. +# AzDO build objects omit 'result' / 'finishTime' until the build is completed, +# and PSObject access throws under strict mode when a property is missing. +function Get-AzdoProp { + param($Obj, [string]$Name) + if ($null -eq $Obj) { return $null } + if (-not ($Obj.PSObject -and $Obj.PSObject.Properties[$Name])) { return $null } + return $Obj.$Name +} + +function Get-PipelineLatestBuilds { + param($Pipeline, [string]$SrBranch, [string]$SrHead) + + $org = $Pipeline.Org + $project = $Pipeline.Project + $defId = $Pipeline.DefinitionId + $branchSpec = "refs/heads/$SrBranch" + + $url = "https://dev.azure.com/$org/$project/_apis/build/builds?definitions=$defId&branchName=$branchSpec&`$top=5&api-version=7.1" + try { + $obj = Invoke-RestMethod -Uri $url -TimeoutSec 30 -ErrorAction Stop + $builds = Get-AzdoProp $obj 'value' + if (-not $builds) { return $null } + return $builds + } catch { + Write-Warn "Failed to query pipeline $($Pipeline.Name): $_" + return $null + } +} + +function Get-CIStatus { + param($Ctx) + + Write-Host "Querying CI pipelines..." -ForegroundColor Cyan + $results = @() + # Internal dnceng/internal pipelines require AzDO auth that the default + # GitHub Actions runner does NOT have — querying them in public CI mode + # always 401s and emits a permanent "unknown" tier-2 escalation. Caller + # must explicitly opt in via -IncludeInternal (e.g. local run with az + # login or PAT) for these to be queried at all. + $allPipelines = if ($IncludeInternal) { + $Script:PublicPipelines + $Script:InternalPipelines + } else { + $Script:PublicPipelines + } + + foreach ($p in $allPipelines) { + $builds = Get-PipelineLatestBuilds -Pipeline $p -SrBranch $Ctx.srBranch -SrHead $Ctx.srHeadSha + if (-not $builds) { + $results += @{ + name = $p.Name; definitionId = $p.DefinitionId + verdict = 'unknown'; latestBuild = $null + url = "https://dev.azure.com/$($p.Org)/$($p.Project)/_build?definitionId=$($p.DefinitionId)&branchFilter=$($Ctx.srBranch)" + note = 'Could not query (auth or outage)' + } + continue + } + + $latest = $builds | Select-Object -First 1 + $sourceSha = Get-AzdoProp $latest 'sourceVersion' + $status = Get-AzdoProp $latest 'status' + $result = Get-AzdoProp $latest 'result' + $finishTime = Get-AzdoProp $latest 'finishTime' + $links = Get-AzdoProp $latest '_links' + $buildUrl = if ($links) { Get-AzdoProp (Get-AzdoProp $links 'web') 'href' } else { $null } + + $isAtOrAhead = $false + if ($sourceSha -and $Ctx.srHeadSha) { + # Is SR HEAD an ancestor of (or equal to) the build's source SHA? + $isAtOrAhead = Test-CommitOnBranch -Sha $Ctx.srHeadSha -BranchRef $sourceSha + } + + $verdict = if (-not $isAtOrAhead) { + 'stale' + } elseif ($status -in @('inProgress','notStarted')) { + 'running' + } elseif ($result -eq 'succeeded') { + 'green' + } elseif ($result -eq 'partiallySucceeded') { + 'red-needs-review' + } elseif ($result -eq 'failed') { + 'red-needs-review' # downstream agent classifies known-flakes vs new + } else { + 'unknown' + } + + $results += @{ + name = $p.Name; definitionId = $p.DefinitionId + verdict = $verdict + latestBuild = @{ + id = Get-AzdoProp $latest 'id' + buildNumber = Get-AzdoProp $latest 'buildNumber' + result = $result + status = $status + sourceSha = $sourceSha + isAtOrAheadOfSrHead = $isAtOrAhead + completedAt = $finishTime + url = $buildUrl + } + recentBuilds = @($builds | Select-Object -First 5 | ForEach-Object { + @{ id = Get-AzdoProp $_ 'id'; result = Get-AzdoProp $_ 'result'; sourceSha = Get-AzdoProp $_ 'sourceVersion'; completedAt = Get-AzdoProp $_ 'finishTime' } + }) + url = "https://dev.azure.com/$($p.Org)/$($p.Project)/_build?definitionId=$($p.DefinitionId)&branchFilter=$($Ctx.srBranch)" + } + } + + # Overall verdict + $overall = 'green' + foreach ($r in $results) { + if ($r.verdict -eq 'stale') { $overall = 'stale'; break } + if ($r.verdict -like 'red-*') { $overall = 'red-needs-review' } + if ($r.verdict -eq 'running' -and $overall -eq 'green') { $overall = 'running' } + if ($r.verdict -eq 'unknown' -and $overall -eq 'green') { $overall = 'partial-unknown' } + } + + @{ overall = $overall; pipelines = $results } +} + +# region ────────────────────── 4. REGRESSION LABEL INFERENCE ────────────── + +function Get-RegressionLabelsAuto { + param($Ctx) + + # Parse SR version from branch name: release/10.0.1xx-sr7 -> 10.0 + $branchMatch = [regex]::Match($Ctx.srBranch, '^release/(\d+)\.(\d+)\.\d+xx-sr(\d+)$') + if (-not $branchMatch.Success) { + return @{ + mode = 'inferred'; confidence = 'low' + labels = @(); error = "Branch name doesn't match SR pattern; pass -RegressionLabels explicitly" + } + } + $major = $branchMatch.Groups[1].Value + $minor = $branchMatch.Groups[2].Value + $srNum = [int]$branchMatch.Groups[3].Value + + # Query existing labels: regressed-in-{major}.{minor}.* + $raw = Invoke-Gh @('api', "repos/$($Ctx.repo)/labels", '--paginate', '--jq', + ".[] | select(.name | test(`"^regressed-in-$major\\.$minor\\.\\d+$`")) | .name") + if (-not $raw) { + return @{ mode = 'inferred'; confidence = 'low'; labels = @(); + error = "No regressed-in-$major.$minor.* labels found in repo" } + } + $allLabels = @($raw) | Sort-Object { + # Sort by numeric patch + [int]([regex]::Match($_, '\.(\d+)$').Groups[1].Value) + } -Descending + + # Heuristic: take top 2 labels — covers the typical SR cycle that aggregates + # two minor version's worth of fixes + $picked = @($allLabels | Select-Object -First 2) + + @{ + mode = 'inferred' + confidence = if ($picked.Count -eq 2) { 'medium' } else { 'low' } + labels = $picked + availableLabels = $allLabels + note = "Inferred from SR$srNum on $major.$minor — VERIFY before treating as authoritative" + } +} + +# region ────────────────────── 5. REGRESSION CANDIDATE ANALYSIS ─────────── + +function Get-IssueTimelinePrs { + param($Repo, $IssueNumber) + $raw = Invoke-Gh @('api', "repos/$Repo/issues/$IssueNumber/timeline", '--paginate') + if (-not $raw) { return @() } + $events = $raw | ConvertFrom-Json -ErrorAction SilentlyContinue + if (-not $events) { return @() } + $prs = @() + foreach ($e in $events) { + # Use PSObject.Properties checks because strict mode forbids accessing + # missing properties on PSCustomObject (timeline events have many shapes). + if (-not $e.PSObject.Properties['event']) { continue } + if ($e.event -ne 'cross-referenced') { continue } + if (-not $e.PSObject.Properties['source']) { continue } + $src = $e.source + if (-not $src) { continue } + if (-not $src.PSObject.Properties['type'] -or $src.type -ne 'issue') { continue } + if (-not $src.PSObject.Properties['issue']) { continue } + $iss = $src.issue + if (-not $iss) { continue } + # `pull_request` member only exists on issues that are actually PRs + if (-not $iss.PSObject.Properties['pull_request']) { continue } + if (-not $iss.pull_request) { continue } + if (-not $iss.PSObject.Properties['number']) { continue } + $prs += [int]$iss.number + } + return @($prs | Sort-Object -Unique) +} + +function Get-PrEvidenceType { + param($PrBody, $IssueNumber) + if (-not $PrBody) { return 'none' } + if ($PrBody -match "(?im)(?:fixes|closes|resolves)\s+(?:dotnet/maui#|#)$IssueNumber\b") { + return 'closing-keyword' + } + if ($PrBody -match '(?im)(?:backport|cherry[-\s]picked)') { + return 'explicit-backport' + } + if ($PrBody -match "#$IssueNumber\b") { return 'mentions-only' } + return 'none' +} + +function Get-PrInfo { + param($Repo, $PrNumber) + $json = Invoke-Gh @('pr', 'view', $PrNumber, '--repo', $Repo, '--json', + 'number,title,state,baseRefName,mergedAt,closedAt,body,mergeCommit,author,labels,isDraft,files') + if (-not $json) { return $null } + return ($json | ConvertFrom-Json -ErrorAction SilentlyContinue) +} + +function Test-PrIsToolingOnly { + <# + .SYNOPSIS + Returns $true when every file changed by the PR lives under .github/ + (or related tooling roots). Such PRs are agent/skill/workflow changes + that mention regression issues for context but are NOT product fixes. + + .DESCRIPTION + Guards against the self-reference false-positive: when an agent or + workflow PR's body says "Fixes #NNNNN" (as documentation context), + the regression classifier could otherwise mistake it for a real fix. + + Returns $false when: + - $Files is null/empty (cannot make a decision -> leave alone) + - ANY file is outside the tooling roots (real product change) + #> + param($Files) + if (-not $Files) { return $false } + $count = 0 + foreach ($f in $Files) { + if (-not $f.path) { continue } + $count++ + # Tooling roots — agent infrastructure, workflows, helper scripts, + # docs. Product code (src/, tests/, etc.) is intentionally excluded. + if ($f.path -notmatch '^(\.github/|eng/scripts/|docs/|README|CONTRIBUTING)') { + return $false + } + } + return ($count -gt 0) +} + +function Test-CommitOnBranch { + param([string]$Sha, [string]$BranchRef) + if (-not $Sha) { return $false } + Invoke-Git "merge-base --is-ancestor $Sha $BranchRef" | Out-Null + return ($LASTEXITCODE -eq 0) +} + +function Get-BackportPrsForSr { + param($Repo, $SrBranch, $SourcePrNumber) + # Look for any PR targeting the SR branch that mentions the source PR + $raw = Invoke-Gh @('pr', 'list', '--repo', $Repo, '--base', $SrBranch, + '--state', 'all', '--search', "$SourcePrNumber in:title,body", + '--json', 'number,title,state,mergedAt,closedAt', '--limit', '20') + if (-not $raw) { return @() } + $list = $raw | ConvertFrom-Json -ErrorAction SilentlyContinue + return @($list) +} + +function Classify-RegressionCandidate { + param($Issue, $CandidatePrs, $Ctx, $SrContents) + + $sourcePrSet = @{} + foreach ($n in $SrContents.sourcePrs) { $sourcePrSet[$n] = $true } + $revertedPrSet = @{} + foreach ($r in $SrContents.reverts) { + if ($r.revertsPr) { $revertedPrSet[$r.revertsPr] = $true } + if ($r.revertBackportPr) { $revertedPrSet[$r.revertBackportPr] = $true } + } + + # === EARLY-EXIT: issue is already fixed by a commit IN the SR contents === + # + # Bug this guards against: some fixes are opened DIRECTLY against an SR branch + # (e.g. urgent partner regressions, SR-hotfix PRs like #35768 against + # release/10.0.1xx-sr7). They have no main-side companion at fix time — + # any later main PR (e.g. #35803) is just forward-flow, not the original fix. + # + # The downstream candidate-PR walk below would happily pick the OPEN main PR + # and classify as 'open-on-main' ("waiting to merge then backport"), even + # though the SR already has the fix. + # + # $SrContents.fixedIssues is the deterministic ground truth: it's populated + # from `Fixes #N` / `Closes #N` closing keywords in the bodies of PRs that + # actually merged into the SR contents (or its inherited prior-SR contents). + # If the issue is in there, the fix has shipped — period. + # + # Defensive: $SrContents shape can be partial in unit-test fixtures (missing + # .commits / .fixedIssues). Production Get-SrCommits always populates both. + $hasCommits = if ($SrContents -is [hashtable]) { $SrContents.ContainsKey('commits') } + else { $SrContents.PSObject.Properties.Name -contains 'commits' } + $fixingSrCommits = @() + if ($hasCommits) { + $fixingSrCommits = @($SrContents.commits | Where-Object { + $_.fixedIssues -and ($_.fixedIssues -contains [int]$Issue.number) + }) + } + if ($fixingSrCommits.Count -gt 0) { + # Determine the canonical SR fix PR (prefer the explicit backport/sourcePr; + # the SR commit always has at least one of those if it was a real PR merge). + $fixPrs = @() + foreach ($c in $fixingSrCommits) { + if ($c.backportPr) { $fixPrs += [int]$c.backportPr } + elseif ($c.sourcePr) { $fixPrs += [int]$c.sourcePr } + } + $fixPrs = @($fixPrs | Sort-Object -Unique) + + # If EVERY fixing PR was reverted on SR, the fix didn't actually ship. + $unreverted = @($fixPrs | Where-Object { -not $revertedPrSet.ContainsKey($_) }) + + if ($unreverted.Count -gt 0) { + $prList = ($unreverted | ForEach-Object { "#$_" }) -join ', ' + return @{ + classification = 'in-sr-active' + confidence = 'high' + evidence = @("SR contents already include a fix for #$($Issue.number) via $prList (closing keyword on merged SR commit)") + candidateFixPrs = @($unreverted | ForEach-Object { + @{ number = $_; baseRef = 'release/*'; state = 'MERGED'; onMain = $false; evidenceType = 'sr-direct-fix'; backports = @(); title = '' } + }) + recommendedAction = 'No action — fix is already shipping in this SR' + } + } elseif ($fixPrs.Count -gt 0) { + # Every fix PR we found was reverted — still surface it as reverted + # so the captain sees the regression isn't actually fixed. + $prList = ($fixPrs | ForEach-Object { "#$_" }) -join ', ' + return @{ + classification = 'in-sr-reverted' + confidence = 'high' + evidence = @("All SR fixes for #$($Issue.number) were reverted on SR: $prList") + candidateFixPrs = @() + recommendedAction = 'Investigate: SR fix was reverted; needs a new fix or revert-of-revert' + } + } + # If we found fixing commits but couldn't extract any PR number, + # fall through to the candidate-PR walk (best-effort). + } + + # Filter candidates to those with high evidence for this issue + $strongPrs = @() + $sawRevertCandidate = $false + foreach ($prNum in $CandidatePrs) { + $info = Get-PrInfo -Repo $Ctx.repo -PrNumber $prNum + if (-not $info) { continue } + $ev = Get-PrEvidenceType -PrBody $info.body -IssueNumber $Issue.number + if ($ev -ne 'closing-keyword' -and $ev -ne 'explicit-backport') { continue } + + # Skip PRs that target SR branches (those are backport PRs themselves — examined separately) + if ($info.baseRefName -like 'release/*') { continue } + + # False-positive guard: skip PRs whose entire change set lives in + # tooling roots (.github/, docs/, eng/scripts/, etc). These are + # agent/skill/workflow PRs that mention regression issue numbers in + # their body for documentation purposes — they're not real fixes. + if (Test-PrIsToolingOnly -Files $info.files) { + Write-Verbose " Skipping #$prNum — tooling-only PR (mentions #$($Issue.number) in body but changes only .github/, docs/, or eng/scripts/)" + continue + } + + # Detect "Revert ..." titled PRs — these are NOT fixes, they're rollbacks. + # When the only candidate PR is a revert, the issue is likely unfixed (or + # in a revert-of-revert chain that needs manual verification). + $isRevertPr = ($info.title -match '(?i)^(?:\[[^\]]+\]\s+)?Revert\b') -or ($info.title -match '\[Revert\]') + if ($isRevertPr) { $sawRevertCandidate = $true; continue } + + $mergeSha = if ($info.mergeCommit) { $info.mergeCommit.oid } else { $null } + $onMain = if ($mergeSha) { Test-CommitOnBranch -Sha $mergeSha -BranchRef "origin/$($Ctx.mainBranch)" } else { $false } + + # Look for backport PRs targeting SR + $backports = Get-BackportPrsForSr -Repo $Ctx.repo -SrBranch $Ctx.srBranch -SourcePrNumber $prNum + + $strongPrs += @{ + number = [int]$info.number + title = $info.title + state = $info.state + baseRef = $info.baseRefName + mergeSha = $mergeSha + mergedAt = $info.mergedAt + evidenceType = $ev + onMain = $onMain + backports = @($backports | ForEach-Object { + @{ number = $_.number; state = $_.state; mergedAt = $_.mergedAt; closedAt = $_.closedAt; title = $_.title } + }) + } + } + + if ($strongPrs.Count -eq 0) { + if ($sawRevertCandidate) { + return @{ + classification = 'needs-human-review' + confidence = 'medium' + evidence = @('All candidate fix PRs were Revert PRs — original fix may be missing or in a revert-of-revert chain. Manual verification required.') + candidateFixPrs = @() + recommendedAction = "Inspect the revert chain manually: original fix → revert → (possible) revert-of-revert. Look for the actual fix PR in `gh pr list --search 'fixes #$($Issue.number)'` excluding revert titles." + } + } + return @{ + classification = 'no-fix-yet' + confidence = 'high' + evidence = @('no candidate PRs with closing-keyword or explicit-backport evidence') + candidateFixPrs = @() + recommendedAction = 'Investigate: no fix PR cross-referenced from issue' + } + } + + # Classify each strong PR; aggregate to issue-level verdict + $perPrVerdicts = @() + foreach ($pr in $strongPrs) { + $verdict = $null + $confidence = 'high' + $evidence = @() + + # In-SR (with revert check) + if ($sourcePrSet.ContainsKey($pr.number)) { + if ($revertedPrSet.ContainsKey($pr.number)) { + $verdict = 'in-sr-reverted' + $evidence += "PR #$($pr.number) source-PR in SR but reverted" + } else { + $verdict = 'in-sr-active' + $evidence += "PR #$($pr.number) source-PR in SR contents (active)" + } + } + else { + # Look at backport PRs targeting SR + $openBackport = $pr.backports | Where-Object { $_.state -eq 'OPEN' } | Select-Object -First 1 + $closedUnmergedBackport = $pr.backports | Where-Object { $_.state -eq 'CLOSED' -and -not $_.mergedAt } | Select-Object -First 1 + $mergedBackport = $pr.backports | Where-Object { $_.state -eq 'MERGED' } | Select-Object -First 1 + + if ($mergedBackport) { + # backport landed but PR # is different from what we tracked → check sourcePrSet for backport # + if ($sourcePrSet.ContainsKey([int]$mergedBackport.number)) { + if ($revertedPrSet.ContainsKey([int]$mergedBackport.number)) { + $verdict = 'in-sr-reverted' + $evidence += "Backport PR #$($mergedBackport.number) in SR but reverted" + } else { + $verdict = 'in-sr-active' + $evidence += "Backport PR #$($mergedBackport.number) in SR (active)" + } + } else { + $verdict = 'needs-human-review' + $confidence = 'low' + $evidence += "Backport PR #$($mergedBackport.number) is MERGED in GitHub but not found in SR git contents — re-run without -NoFetch or verify the merge target manually" + } + } + elseif ($openBackport) { + $verdict = 'backport-in-progress' + $evidence += "Backport PR #$($openBackport.number) is OPEN against $($Ctx.srBranch)" + } + elseif ($closedUnmergedBackport) { + $verdict = 'rejected-from-sr' + $evidence += "Backport PR #$($closedUnmergedBackport.number) CLOSED unmerged — needs WorkIQ for context" + } + elseif ($pr.state -eq 'MERGED') { + if ($pr.onMain) { + $verdict = 'merged-on-main-no-backport' + $confidence = 'medium' + $evidence += "PR #$($pr.number) merged to main, no backport PR opened" + } else { + $verdict = 'merged-non-main-only' + $confidence = 'medium' + $evidence += "PR #$($pr.number) merged but NOT on main (likely inflight-only)" + } + } + elseif ($pr.state -eq 'OPEN') { + $verdict = 'open-on-main' + $evidence += "PR #$($pr.number) is OPEN, base=$($pr.baseRef)" + } + else { + $verdict = 'needs-human-review' + $confidence = 'low' + $evidence += "PR #$($pr.number) in unexpected state: $($pr.state)" + } + } + + $perPrVerdicts += @{ pr = $pr; verdict = $verdict; confidence = $confidence; evidence = $evidence } + } + + # Pick the highest-priority verdict (in-sr-active > backport-in-progress > ... > no-fix-yet) + $priority = @{ + 'in-sr-active' = 1 + 'in-sr-reverted' = 2 + 'backport-in-progress' = 3 + 'rejected-from-sr' = 4 + 'merged-on-main-no-backport' = 5 + 'merged-non-main-only' = 6 + 'open-on-main' = 7 + 'needs-human-review' = 8 + 'no-fix-yet' = 9 + } + $best = $perPrVerdicts | Sort-Object { $priority[$_.verdict] } | Select-Object -First 1 + + $recAction = switch ($best.verdict) { + 'in-sr-active' { 'No action — fix is shipping' } + 'in-sr-reverted' { 'Investigate: backport landed and was reverted on SR' } + 'rejected-from-sr' { 'Check rejection rationale (WorkIQ) — was this intentional or stale?' } + 'backport-in-progress' { 'Track backport PR to completion' } + 'merged-on-main-no-backport' { 'Open a backport PR to SR' } + 'merged-non-main-only' { 'Flow fix to main first, then backport to SR' } + 'open-on-main' { 'Wait for main merge, then open backport' } + 'no-fix-yet' { 'No fix exists — investigate priority' } + default { 'Manual review required' } + } + + @{ + classification = $best.verdict + confidence = $best.confidence + evidence = $best.evidence + candidateFixPrs = @($strongPrs | ForEach-Object { @{ + number = $_.number; title = $_.title; state = $_.state + baseRef = $_.baseRef; evidenceType = $_.evidenceType + onMain = $_.onMain; backports = $_.backports + }}) + recommendedAction = $recAction + } +} + +function Get-RegressionCandidates { + param($Ctx, $Labels, $SrContents, [int]$MaxIssues) + + Write-Host "Scanning regression issues for labels: $($Labels -join ', ')" -ForegroundColor Cyan + $allIssues = @() + $seen = @{} + + foreach ($label in $Labels) { + $raw = Invoke-Gh @('issue', 'list', '--repo', $Ctx.repo, '--label', $label, + '--state', 'all', '--limit', $MaxIssues.ToString(), + '--json', 'number,title,state,stateReason,labels,milestone,createdAt,closedAt') + if (-not $raw) { continue } + $list = $raw | ConvertFrom-Json -ErrorAction SilentlyContinue + foreach ($iss in $list) { + if (-not $seen.ContainsKey($iss.number)) { + $seen[$iss.number] = $true + $allIssues += $iss + } + } + } + Write-Host " Found $($allIssues.Count) unique regression issues" -ForegroundColor Gray + + # === Future-SR scope guard === + # Three deterministic signals identify issues that match the regression + # label set but actually belong to a DIFFERENT SR (typically the next one): + # + # (1) Versioned label is for a future SR. + # e.g. SR8 readiness + `regressed-in-10.0.90` → SR9 candidate. + # + # (2) Milestone explicitly names a different SR. + # e.g. SR8 readiness + milestone `.NET 10 SR9` → SR9 candidate. + # Triagers set milestones as the canonical "which cycle owns this". + # + # (3) Only label is `regressed-in-inflight/current` AND main has been + # bumped past this SR's cycle. + # e.g. SR8 readiness + main's PatchVersion = 90 → "inflight/current" + # describes content that's now SR9-bound. Reuses the same Versions.props + # inspection the "Main bumped to next cycle" ship check uses. + # + # In-scope range for SR-N (cycleNum): patch ∈ [cycleNum*10, (cycleNum+1)*10 - 1] + # (covers SR8 = 80..89, accommodating hotfix patches like 81/82/...) + $srBranchMatch = [regex]::Match($Ctx.srBranch, '^release/(\d+)\.0\.\d+xx-sr(\d+)$') + $scopeMajor = $null; $scopeMinPatch = $null; $scopeMaxPatch = $null; $scopeCycleNum = $null + if ($srBranchMatch.Success) { + $scopeMajor = [int]$srBranchMatch.Groups[1].Value + $scopeCycleNum = [int]$srBranchMatch.Groups[2].Value + $scopeMinPatch = $scopeCycleNum * 10 + $scopeMaxPatch = ($scopeCycleNum + 1) * 10 - 1 + } + + # Signal (3): probe main's PatchVersion to know which cycle main is on. + # If main is past this SR's cycle, `regressed-in-inflight/current` no + # longer points at THIS SR's content. + $mainIsPastThisSr = $false + if ($scopeMajor -and $Ctx.mainBranch) { + try { + $vpMain = Get-VersionsPropsState -Ref "origin/$($Ctx.mainBranch)" + if ($vpMain -and $vpMain.Patch -gt $scopeMaxPatch) { + $mainIsPastThisSr = $true + } + } catch { + # If we can't read main's Versions.props, fall back to label-only logic. + } + } + + $results = @() + $i = 0 + foreach ($iss in $allIssues) { + $i++ + Write-Host " [$i/$($allIssues.Count)] Issue #$($iss.number)..." -ForegroundColor DarkGray + + # False-positive guard: issues closed as DUPLICATE are not regressions + # against this SR — they were rolled up into a canonical issue. Skip + # the expensive PR walk and flag them so the report can surface them + # under an "informational" tier instead of "no fix yet". + $isDuplicate = ($iss.state -eq 'CLOSED') -and ($iss.PSObject.Properties['stateReason']) -and ($iss.stateReason -eq 'DUPLICATE') + if ($isDuplicate) { + $results += @{ + issue = [int]$iss.number + title = $iss.title + state = $iss.state + stateReason = $iss.stateReason + labels = @($iss.labels.name) + milestone = if ($iss.milestone) { $iss.milestone.title } else { $null } + createdAt = $iss.createdAt + closedAt = $iss.closedAt + classification = 'closed-as-duplicate' + confidence = 'high' + evidence = @("Issue closed with stateReason=DUPLICATE — rolled up into a canonical regression. Inspect the closing comment for the canonical issue reference.") + candidateFixPrs = @() + recommendedAction = 'Confirm the canonical issue (visible in the close comment) is tracked separately. No action on this issue.' + } + continue + } + + # Future-SR scope check (only when we know this SR's version range) + if ($scopeMajor) { + $issueLabels = @($iss.labels.name) + $issueMilestone = if ($iss.milestone) { $iss.milestone.title } else { $null } + + # --- Signal (1): versioned regression labels (HIGHEST priority) --- + # A `regressed-in-X.Y.Z` label states a historical fact ("the + # regression appeared in X.Y.Z"). If the user explicitly named + # that label in -RegressionLabels (or it's within this SR's patch + # range), the issue is IN-SCOPE regardless of milestone — the bug + # is still present in this SR even if triagers plan to ship the + # fix in a later SR (which would show up as a milestone mismatch). + $versionedRegressionLabels = @() + foreach ($lbl in $issueLabels) { + $vm = [regex]::Match($lbl, '^regressed-in-(\d+)\.0\.(\d+)$') + if ($vm.Success) { + $versionedRegressionLabels += @{ + label = $lbl + major = [int]$vm.Groups[1].Value + patch = [int]$vm.Groups[2].Value + } + } + } + $anyLabelInScope = $false + foreach ($vrl in $versionedRegressionLabels) { + if ($vrl.major -eq $scopeMajor -and $vrl.patch -ge $scopeMinPatch -and $vrl.patch -le $scopeMaxPatch) { + $anyLabelInScope = $true; break + } + # Allow PRIOR SRs that the user explicitly named in -Labels (carry-over scope) + if (($vrl.major -lt $scopeMajor) -or + ($vrl.major -eq $scopeMajor -and $vrl.patch -lt $scopeMinPatch)) { + if ($Labels -contains $vrl.label) { $anyLabelInScope = $true; break } + } + } + + # If any versioned label puts the issue in scope, do NOT exclude it. + # Milestone-mismatch / inflight-bumped signals are subordinate. + if (-not $anyLabelInScope) { + $evidence = $null + + # --- Signal (1b): all versioned labels point to a different SR --- + if ($versionedRegressionLabels.Count -gt 0) { + $futureList = ($versionedRegressionLabels | ForEach-Object { $_.label }) -join ', ' + $evidence = "Versioned label(s) $futureList map to a different SR (this SR covers patches $scopeMinPatch..$scopeMaxPatch)." + } + + # --- Signal (2): explicit milestone for a different SR --- + if (-not $evidence -and $issueMilestone) { + $mm = [regex]::Match($issueMilestone, '^\.NET\s+(\d+)(?:\.0)?\s+SR(\d+)$') + if ($mm.Success) { + $milestoneMajor = [int]$mm.Groups[1].Value + $milestoneCycleNum = [int]$mm.Groups[2].Value + if ($milestoneMajor -ne $scopeMajor -or $milestoneCycleNum -ne $scopeCycleNum) { + $evidence = "Milestone ``$issueMilestone`` is a different SR cycle than this readiness scope (.NET $scopeMajor SR$scopeCycleNum). The triager assigned it to a different SR — treat as out of scope here." + } + } + } + + # --- Signal (3): only `regressed-in-inflight/current` AND main has moved past --- + if (-not $evidence -and $mainIsPastThisSr -and $versionedRegressionLabels.Count -eq 0) { + if ($issueLabels -contains 'regressed-in-inflight/current') { + $mainPatchStr = if ($vpMain) { $vpMain.Patch } else { '(unknown)' } + $evidence = "Only regression label is ``regressed-in-inflight/current``, and ``origin/$($Ctx.mainBranch)`` has been bumped to PatchVersion $mainPatchStr (past this SR's cycle $scopeMinPatch..$scopeMaxPatch). 'inflight' now describes the next SR's content, not this one." + } + } + + if ($evidence) { + $results += @{ + issue = [int]$iss.number + title = $iss.title + state = $iss.state + stateReason = if ($iss.PSObject.Properties['stateReason']) { $iss.stateReason } else { $null } + labels = $issueLabels + milestone = $issueMilestone + createdAt = $iss.createdAt + closedAt = if ($iss.PSObject.Properties['closedAt']) { $iss.closedAt } else { $null } + classification = 'out-of-scope-future-sr' + confidence = 'high' + evidence = @($evidence) + candidateFixPrs = @() + recommendedAction = "Out of scope for this SR. Will be tracked under the relevant SR's readiness." + } + continue + } + } + } + + $candidatePrs = Get-IssueTimelinePrs -Repo $Ctx.repo -IssueNumber $iss.number + $classify = Classify-RegressionCandidate -Issue $iss -CandidatePrs $candidatePrs ` + -Ctx $Ctx -SrContents $SrContents + + $results += @{ + issue = [int]$iss.number + title = $iss.title + state = $iss.state + stateReason = if ($iss.PSObject.Properties['stateReason']) { $iss.stateReason } else { $null } + labels = @($iss.labels.name) + milestone = if ($iss.milestone) { $iss.milestone.title } else { $null } + createdAt = $iss.createdAt + closedAt = if ($iss.PSObject.Properties['closedAt']) { $iss.closedAt } else { $null } + classification = $classify.classification + confidence = $classify.confidence + evidence = $classify.evidence + candidateFixPrs = $classify.candidateFixPrs + recommendedAction = $classify.recommendedAction + } + } + return $results +} + +# region ────────────────────── 6. OPEN SR-TARGETING PRs ─────────────────── + +function Get-OpenSrPrs { + param($Ctx) + Write-Host "Listing open PRs targeting $($Ctx.srBranch)..." -ForegroundColor Cyan + $raw = Invoke-Gh @('pr', 'list', '--repo', $Ctx.repo, '--base', $Ctx.srBranch, + '--state', 'open', '--limit', '100', + '--json', 'number,title,author,isDraft,createdAt,updatedAt,labels,reviewDecision') + if (-not $raw) { return @() } + return @($raw | ConvertFrom-Json -ErrorAction SilentlyContinue) +} + +function Get-OpenIssuesByLabel { + <# + .SYNOPSIS + Returns open issues labeled $Label with an error envelope. + .DESCRIPTION + Returns @{ QueryFailed=[bool]; Issues=[array] }. Wrapping the + result distinguishes "no issues found" from "query failed" — + without it, downstream signal checks emit a false-green READY + when gh fails (auth expired, rate-limited, network outage) since + `if (-not $issues)` matches both cases. + .PARAMETER IncludeBody + Include the issue body in the result. Kept for historical callers; + new code doesn't need it because branch filtering now uses the + label name (Get-CiScanLabelForBranch) instead of body markers. + #> + param( + [string]$Label, + [switch]$IncludeBody + ) + + $fields = 'number,title,url,labels,createdAt,updatedAt' + if ($IncludeBody) { $fields += ',body' } + + $raw = Invoke-Gh @('issue', 'list', '--repo', $script:Repo, '--state', 'open', + '--limit', '100', '--label', $Label, + '--json', $fields) + if ($null -eq $raw) { + # Invoke-Gh returns $null only on non-zero exit (failure). A + # successful but empty result is '[]', a non-null string. Treat + # this case as "query failed" so callers can downgrade to WATCH. + return @{ QueryFailed = $true; Issues = @() } + } + $issues = $raw | ConvertFrom-Json -ErrorAction SilentlyContinue + if (-not $issues) { return @{ QueryFailed = $false; Issues = @() } } + return @{ QueryFailed = $false; Issues = @($issues) } +} + +function Get-CiScanLabelForBranch { + <# + .SYNOPSIS + Maps a branch name to the single `ci-scan*` label its scanner + workflow writes. Returns $null when no scanner runs against that + branch. + .DESCRIPTION + The CI Failure Scanner has one workflow per scanned branch + (.github/workflows/ci-status-main.md → 'main' → 'ci-scan'; + .github/workflows/ci-status-net11.md → 'net11.0' → 'ci-scan-net11'). + The label name fully encodes the branch — no need to crack the + issue body open to figure out where it came from. + + Mapping: + main → ci-scan + netN.0 → ci-scan-netN + release/N.0.xx-previewM → ci-scan-netN (upstream) + release/N.0.xx-srM → $null (no scanner) + anything else → $null (no scanner) + + Preview branches return the parent net.0 label so an in-flight + preview readiness check still surfaces signals from the branch the + preview was cut from. SR branches have no continuous scanner, so + their ci-scan set is correctly empty. + + Add a case here when a new ci-status-*.md workflow is introduced + (e.g. for a future netN.0 — see .github/workflows/ci-status-*.md). + #> + param([string]$Branch) + + if ([string]::IsNullOrWhiteSpace($Branch)) { return $null } + if ($Branch -eq 'main') { return 'ci-scan' } + if ($Branch -match '^net(\d+)\.0$') { return "ci-scan-net$($Matches[1])" } + if ($Branch -match '^release/(\d+)\.0\.\d+xx-preview\d+$') { + return "ci-scan-net$($Matches[1])" + } + return $null +} + +function Get-CiScanIssuesForSr { + <# + .SYNOPSIS + Returns open ci-scan issues for the scanner attached to $Branch. + Returns @{ Matched=[array]; FilteredOut=int; Total=int; QueryFailed=[bool]; ScannerLabel=[string]|$null }. + .DESCRIPTION + Uses Get-CiScanLabelForBranch to resolve the single relevant label + and queries only that one — no more cross-branch dedup or body + marker parsing. When the branch has no scanner (most SR branches), + ScannerLabel is $null and Matched is empty. + + QueryFailed flips $true if the underlying `gh issue list` call + failed (gh missing, auth expired, transient outage). Callers must + treat that case as "no signal" rather than "no issues" to avoid + emitting a false-green READY on tool failure. + #> + param([string]$Branch) + + $label = Get-CiScanLabelForBranch -Branch $Branch + if (-not $label) { + return @{ + Matched = @() + FilteredOut = 0 + Total = 0 + QueryFailed = $false + ScannerLabel = $null + } + } + + $result = Get-OpenIssuesByLabel -Label $label -IncludeBody + if ($result.QueryFailed) { + return @{ + Matched = @() + FilteredOut = 0 + Total = 0 + QueryFailed = $true + ScannerLabel = $label + } + } + + $sorted = @($result.Issues | Sort-Object { + $u = ConvertTo-Utc -Value $_.createdAt + if ($u) { $u } else { [DateTime]::MinValue } + } -Descending) + + return @{ + Matched = $sorted + FilteredOut = 0 + Total = $sorted.Count + QueryFailed = $false + ScannerLabel = $label + } +} + +function Test-CiScanIsFresh { + <# + .SYNOPSIS + Returns $true if the ci-scan issue was filed within the last $HoursThreshold + hours (default 24). Used to escalate the ship-check to WATCH. + #> + param($Issue, [int]$HoursThreshold = 24) + if (-not $Issue.PSObject.Properties['createdAt'] -or -not $Issue.createdAt) { return $false } + $createdUtc = ConvertTo-Utc -Value $Issue.createdAt + if (-not $createdUtc) { return $false } + return ((Get-Date).ToUniversalTime() - $createdUtc).TotalHours -lt $HoursThreshold +} + +function Get-CiSignalChecks { + <# + .SYNOPSIS + Builds two readiness-check records: + 1. CI Failure Scanner signals (ci-scan label, filtered to $Branch, escalates if any <24h) + 2. Known Build Errors (KBE label, WATCH if any open, READY otherwise) + Returns @{ Checks = [array]; CiScanIssues = [array]; CiScanFilteredOut = [int]; KbeIssues = [array] }. + .PARAMETER Branch + The branch whose ci-scan signals to surface. The scanner-label + mapping (Get-CiScanLabelForBranch) decides which `ci-scan*` label + to query; branches without a per-branch scanner (most SR branches) + emit a 'no scanner' READY entry instead of a confusing 'no signals'. + #> + param([string]$Branch) + + Write-Host "Querying ci-scan and Known Build Error issue lists..." -ForegroundColor Cyan + $ciScanResult = Get-CiScanIssuesForSr -Branch $Branch + $ciScan = @($ciScanResult.Matched) + $ciScanFilteredOut = $ciScanResult.FilteredOut + $ciScanQueryFailed = [bool]$ciScanResult.QueryFailed + $ciScanLabel = $ciScanResult.ScannerLabel + $kbeResult = Get-OpenIssuesByLabel -Label 'Known Build Error' + $kbe = @($kbeResult.Issues) + $kbeQueryFailed = [bool]$kbeResult.QueryFailed + + $checks = @() + + if ($ciScanQueryFailed) { + # gh failed (auth/network/rate-limit). Emit WATCH so the verdict + # acknowledges the missing signal instead of silently READY-ing. + $checks += New-ReadinessCheck ` + -Area 'CI Failure Scanner signals' ` + -Status 'WATCH' ` + -Details "Could not query ci-scan issues (label ``$ciScanLabel`` — gh exited non-zero). Treating as unknown signal so the verdict reflects the missing data." ` + -NextAction "Verify ``gh auth status`` and rerun. If gh is unavailable in this environment, accept the WATCH and triage ci-scan manually." + } elseif (-not $ciScanLabel) { + # No scanner runs against this branch — that's expected for SR + # branches, which are not continuously scanned. Distinguish this + # from 'scanner ran and found nothing' so the report is honest. + $checks += New-ReadinessCheck ` + -Area 'CI Failure Scanner signals' ` + -Status 'READY' ` + -Details "No per-branch CI Failure Scanner is configured for ``$Branch``. Add an entry to Get-CiScanLabelForBranch if a scanner is added later." ` + -NextAction 'No action — SR branches are not continuously scanned.' + } else { + $fresh = @($ciScan | Where-Object { Test-CiScanIsFresh -Issue $_ -HoursThreshold 24 }) + if ($fresh.Count -gt 0) { + $checks += New-ReadinessCheck ` + -Area 'CI Failure Scanner signals' ` + -Status 'WATCH' ` + -Details "$($fresh.Count) ci-scan issue(s) on ``$Branch`` (label ``$ciScanLabel``) filed in the last 24h ($($ciScan.Count) total open). Likely affects this release." ` + -NextAction 'Review the freshest ci-scan issues to confirm none block ship.' + } elseif ($ciScan.Count -gt 0) { + $checks += New-ReadinessCheck ` + -Area 'CI Failure Scanner signals' ` + -Status 'WATCH' ` + -Details "$($ciScan.Count) open ci-scan issue(s) on ``$Branch`` (label ``$ciScanLabel``, none filed in the last 24h)." ` + -NextAction 'Skim recent ci-scan issues for impact patterns; mark accepted-known if appropriate.' + } else { + $checks += New-ReadinessCheck ` + -Area 'CI Failure Scanner signals' ` + -Status 'READY' ` + -Details "No open ci-scan issues on ``$Branch`` (label ``$ciScanLabel``) — scanner has not flagged recurring CI failures." ` + -NextAction 'Continue monitoring.' + } + } + + if ($kbeQueryFailed) { + $checks += New-ReadinessCheck ` + -Area 'Known Build Errors' ` + -Status 'WATCH' ` + -Details 'Could not query the Known Build Error issue list (gh exited non-zero). Treating as unknown signal so the verdict reflects the missing data.' ` + -NextAction "Verify ``gh auth status`` and rerun. If gh is unavailable, triage Known Build Error issues manually." + } elseif ($kbe.Count -gt 0) { + $checks += New-ReadinessCheck ` + -Area 'Known Build Errors' ` + -Status 'WATCH' ` + -Details "$($kbe.Count) open Known Build Error issue(s). May explain background CI noise." ` + -NextAction 'Cross-check against any SR build failures to distinguish accepted-known vs new regressions.' + } else { + $checks += New-ReadinessCheck ` + -Area 'Known Build Errors' ` + -Status 'READY' ` + -Details 'No open Known Build Error issues found.' ` + -NextAction 'Continue monitoring.' + } + + return @{ + Checks = $checks + CiScanIssues = $ciScan + CiScanFilteredOut = $ciScanFilteredOut + KbeIssues = $kbe + } +} + +# region ────────────────────── 7. MARKDOWN REPORT ───────────────────────── + +function Get-VerdictTier { + <# + .SYNOPSIS + Maps a regression-issue classification to a deterministic readiness tier. + + .DESCRIPTION + Tier 1 (🔴 blocking): classifications that PREVENT shipping the SR. + Tier 2 (🟡 risk): classifications that REQUIRE human review/decision. + Tier 3 (🟢 informational): classifications that ARE NOT actionable. + + The mapping is intentionally simple and deterministic — no scoring, + no judgement calls. If the rules need adjustment, edit this table. + #> + param([string]$Classification) + switch ($Classification) { + 'in-sr-reverted' { 1; break } + 'no-fix-yet' { 1; break } + 'rejected-from-sr' { 2; break } + 'backport-in-progress' { 2; break } + 'merged-on-main-no-backport' { 2; break } + 'merged-non-main-only' { 2; break } + 'open-on-main' { 2; break } + 'needs-human-review' { 2; break } + 'in-sr-active' { 3; break } + 'closed-as-duplicate' { 3; break } + 'out-of-scope-future-sr' { 3; break } + default { 2 } # unknown → treat as risk + } +} + +function Get-OverallVerdict { + <# + .SYNOPSIS + Computes a deterministic 🔴/🟡/🟢 overall verdict from a readiness report. + + .DESCRIPTION + Rules (evaluated in order, first match wins): + + 🔴 Not Ready when ANY of: + - One or more regression classifications in Tier 1 + (in-sr-reverted, no-fix-yet for an OPEN regression issue) + 🟡 Conditionally Ready when ANY of: + - One or more Tier 2 classifications + - SR CI overall verdict is 'red-needs-review', 'stale', + 'partial-unknown', or 'unknown' (NOT candidate) + + 🟢 Ready otherwise. + + For candidate / pre-flight mode, CI staleness is non-blocking and + downgraded to advisory (the SR branch doesn't exist yet — staleness + of main's CI is normal cycle-time noise). + + .OUTPUTS + Hashtable with fields: + symbol = 🔴 / 🟡 / 🟢 + tier = 1 / 2 / 3 + label = 'Not Ready' / 'Conditionally Ready' / 'Ready' + reasons = string[] explaining each contributing factor + #> + param($Data) + + $isCandidate = $false + if ($Data.metadata.ContainsKey('mode') -and $Data.metadata['mode'] -eq 'candidate') { + $isCandidate = $true + } + + $reasons = New-Object System.Collections.Generic.List[string] + $tier1 = $false + $tier2 = $false + + # Regression classifications + if ($Data.ContainsKey('regressions') -and $Data['regressions']) { + $t1Counts = @{} + $t2Counts = @{} + foreach ($r in $Data['regressions']) { + $tier = Get-VerdictTier -Classification $r.classification + # `no-fix-yet` only blocks if the issue is still OPEN + if ($r.classification -eq 'no-fix-yet' -and $r.state -ne 'OPEN') { + $tier = 3 + } + if ($tier -eq 1) { + if (-not $t1Counts.ContainsKey($r.classification)) { $t1Counts[$r.classification] = 0 } + $t1Counts[$r.classification]++ + $tier1 = $true + } elseif ($tier -eq 2) { + if (-not $t2Counts.ContainsKey($r.classification)) { $t2Counts[$r.classification] = 0 } + $t2Counts[$r.classification]++ + $tier2 = $true + } + } + foreach ($k in $t1Counts.Keys | Sort-Object) { + $reasons.Add("[Tier 1] $($t1Counts[$k]) × ``$k``") | Out-Null + } + foreach ($k in $t2Counts.Keys | Sort-Object) { + $reasons.Add("[Tier 2] $($t2Counts[$k]) × ``$k``") | Out-Null + } + } + + # CI status (skipped for candidate mode — main CI is naturally noisy) + if (-not $isCandidate -and $Data.ContainsKey('ci') -and $Data['ci']) { + switch ($Data['ci'].overall) { + 'red-needs-review' { + $tier2 = $true + $reasons.Add("[Tier 2] CI on SR branch: ``red-needs-review`` — investigate failures before judging") | Out-Null + } + 'stale' { + $tier2 = $true + $reasons.Add("[Tier 2] CI on SR branch: ``stale`` — re-run before judging") | Out-Null + } + 'partial-unknown' { + $tier2 = $true + $reasons.Add("[Tier 2] CI verdict ``partial-unknown`` — one or more pipeline queries failed") | Out-Null + } + 'unknown' { + $tier2 = $true + $reasons.Add("[Tier 2] CI verdict ``unknown`` — could not query pipeline") | Out-Null + } + } + } elseif ($isCandidate -and $Data.ContainsKey('ci') -and $Data['ci'] -and + $Data['ci'].overall -in @('red-needs-review', 'stale', 'partial-unknown', 'unknown')) { + $reasons.Add("[Advisory] Candidate mode — main CI is ``$($Data['ci'].overall)``. Re-evaluate after SR cut.") | Out-Null + } + + # Ship-readiness checks (versions.props bumped, bug template updated, + # ci-scan/KBE signals, etc.). Mirrors the worst-wins escalation used by + # Get-PreviewReadiness: + # - BLOCKED → Tier 1 (Not Ready). Must be resolved before ship. + # - WATCH → Tier 2 (Conditionally Ready). Worth eyeballing; doesn't + # block but the verdict acknowledges the soft signal. + # CLEANUP and UNKNOWN do NOT escalate the verdict — they're follow-ups + # or missing data, not ship signals. + if ($Data.ContainsKey('shipChecks') -and $Data['shipChecks']) { + $blockedShipChecks = @($Data['shipChecks'] | Where-Object { $_.Status -eq 'BLOCKED' }) + foreach ($sc in $blockedShipChecks) { + $tier1 = $true + $reasons.Add("[Tier 1] Ship check BLOCKED: $($sc.Area)") | Out-Null + } + $watchShipChecks = @($Data['shipChecks'] | Where-Object { $_.Status -eq 'WATCH' }) + foreach ($sc in $watchShipChecks) { + $tier2 = $true + $reasons.Add("[Tier 2] Ship check WATCH: $($sc.Area)") | Out-Null + } + } + + if ($tier1) { + return @{ + symbol = '🔴' + tier = 1 + label = 'Not Ready' + reasons = $reasons.ToArray() + } + } + if ($tier2) { + return @{ + symbol = '🟡' + tier = 2 + label = 'Conditionally Ready' + reasons = $reasons.ToArray() + } + } + return @{ + symbol = '🟢' + tier = 3 + label = 'Ready' + reasons = if ($reasons.Count -gt 0) { $reasons.ToArray() } else { @('No blocking or risk-tier signals detected.') } + } +} + +function ConvertTo-LinkedSha { + <# + .SYNOPSIS Linkify a commit SHA in markdown using $RepoUrl. + #> + param([string]$Sha, [string]$RepoUrl) + if (-not $Sha) { return '?' } + $short = if ($Sha.Length -ge 8) { $Sha.Substring(0, 8) } else { $Sha } + if (-not $RepoUrl) { return "``$short``" } + return "[``$short``]($RepoUrl/commit/$Sha)" +} + +function ConvertTo-LinkedPr { + <# + .SYNOPSIS Linkify a PR number in markdown using $RepoUrl. + #> + param($PrNumber, [string]$RepoUrl) + if (-not $PrNumber) { return '—' } + if (-not $RepoUrl) { return "#$PrNumber" } + return "[#$PrNumber]($RepoUrl/pull/$PrNumber)" +} + +function Format-CiScanIssueRows { + <# + .SYNOPSIS + Builds the rows of the ci-scan section for the SR markdown report. + Returns the table body as a single string (already terminated with newlines). + Returns $null if there's nothing to render. Fresh issues (<24h) are + flagged with 🆕. + #> + param([array]$Issues, [string]$RepoUrl, [int]$MaxRows = 15) + if (-not $Issues -or $Issues.Count -eq 0) { return $null } + + $sb = [System.Text.StringBuilder]::new() + [void]$sb.AppendLine('| Issue | Title | Filed |') + [void]$sb.AppendLine('|---|---|---|') + $rows = $Issues | Select-Object -First $MaxRows + foreach ($iss in $rows) { + $marker = '' + $ageDisplay = '—' + if ($iss.PSObject.Properties['createdAt'] -and $iss.createdAt) { + $createdUtc = ConvertTo-Utc -Value $iss.createdAt + if ($createdUtc) { + $hoursAgo = ((Get-Date).ToUniversalTime() - $createdUtc).TotalHours + $ageDisplay = if ($hoursAgo -lt 24) { '{0:N0}h ago' -f $hoursAgo } + else { '{0:N0}d ago' -f ($hoursAgo / 24) } + if ($hoursAgo -lt 24) { $marker = '🆕 ' } + } + } + $issLink = "[#$($iss.number)]($RepoUrl/issues/$($iss.number))" + $title = ($iss.title -replace '\|', '\|').Trim() + [void]$sb.AppendLine("| $marker$issLink | $title | $ageDisplay |") + } + if ($Issues.Count -gt $MaxRows) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("_…and $($Issues.Count - $MaxRows) more. Full list: [open ci-scan issues]($RepoUrl/issues?q=is%3Aopen+is%3Aissue+label%3Aci-scan+sort%3Acreated-desc)._") + } + return $sb.ToString() +} + +function ConvertTo-Utc { + <# + .SYNOPSIS + Normalizes a value that may be a DateTime (Utc/Local/Unspecified) or a + string into a UTC DateTime. Returns $null if conversion fails. + .NOTES + `ConvertFrom-Json` already parses ISO-8601 'Z' strings into DateTime + with Kind=Utc. But `[DateTime]::Parse(...)` on a string produces + Kind=Unspecified, which `.ToUniversalTime()` then misinterprets as + Local — silently shifting the value by the host's UTC offset. Use + this helper everywhere age/freshness is computed. + #> + param([object]$Value) + + if ($null -eq $Value) { return $null } + + if ($Value -is [DateTime]) { + if ($Value.Kind -eq [DateTimeKind]::Utc) { return $Value } + if ($Value.Kind -eq [DateTimeKind]::Local) { return $Value.ToUniversalTime() } + # Unspecified — assume UTC (gh JSON normally returns 'Z' suffix) + return [DateTime]::SpecifyKind($Value, [DateTimeKind]::Utc) + } + + try { + $dto = [DateTimeOffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture) + return $dto.UtcDateTime + } catch { + return $null + } +} + +function Format-GitHubHandle { + <# + .SYNOPSIS Render a GitHub login as a code span so it does NOT trigger an @-mention notification. + .DESCRIPTION + GitHub treats `@username` in issue/PR bodies as a notification mention. To safely surface + an author's handle in a report (without spamming them on every nightly run), wrap the + login in backticks: `` `username` `` is rendered as a code span and is NOT interpreted as a mention. + Handles bot/app refs (e.g. ``app/dotnet-maestro``) as well. + .PARAMETER Login + The raw GitHub login (with or without a leading ``@``). May be ``$null`` / empty. + .PARAMETER Fallback + Text to return when Login is null/empty. Defaults to ``unknown``. + #> + param( + [Parameter(Mandatory = $false)][AllowNull()][AllowEmptyString()][string]$Login, + [string]$Fallback = 'unknown' + ) + if ([string]::IsNullOrWhiteSpace($Login)) { return $Fallback } + $clean = $Login.TrimStart('@').Trim() + if ([string]::IsNullOrWhiteSpace($clean)) { return $Fallback } + return "``$clean``" +} + +function Get-ReportSemanticHash { + <# + .SYNOPSIS + Produces a stable SHA-256 hash of the report's semantic content. + + .DESCRIPTION + The hash captures fields that change ONLY when the report's verdict + or contents would meaningfully differ — used by the workflow to skip + re-posting unchanged trackers (idempotency). + + DELIBERATELY EXCLUDED: fetchedAt timestamp, CI duration, "X minutes + ago" relative times, and any other field that drifts on every run. + #> + param($Data, $Verdict) + + # MUST be [ordered]: a plain [hashtable] enumerates keys in an order derived + # from per-process String.GetHashCode(), which .NET Core randomizes on every + # process start. ConvertTo-Json would then emit keys in a different order each + # run, producing a DIFFERENT hash for identical content — silently defeating + # the workflow's idempotent no-op (which compares a hash written by an earlier + # process against one computed now). Insertion order keeps the hash stable. + $semantic = [ordered]@{ + verdict = $Verdict.symbol + srHead = $Data.metadata.srHeadSha + ciOverall = if ($Data.ContainsKey('ci') -and $Data['ci']) { $Data['ci'].overall } else { $null } + srPrs = if ($Data.ContainsKey('srContents') -and $Data['srContents']) { + @($Data['srContents'].sourcePrs | Sort-Object) -join ',' + } else { '' } + regressions = if ($Data.ContainsKey('regressions') -and $Data['regressions']) { + @($Data['regressions'] | Sort-Object issue | ForEach-Object { + "$($_.issue):$($_.classification)" + }) -join '|' + } else { '' } + openSrPrs = if ($Data.ContainsKey('openSrPrs') -and $Data['openSrPrs']) { + @($Data['openSrPrs'] | Sort-Object number | ForEach-Object { $_.number }) -join ',' + } else { '' } + shipChecks = if ($Data.ContainsKey('shipChecks') -and $Data['shipChecks']) { + @($Data['shipChecks'] | Sort-Object Area | ForEach-Object { + "$($_.Area):$($_.Status)" + }) -join '|' + } else { '' } + } + + $json = $semantic | ConvertTo-Json -Depth 5 -Compress + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha.ComputeHash($bytes) + return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() + } finally { + $sha.Dispose() + } +} + +function Format-MarkdownReport { + param($Data, [string]$RepoUrl, [string]$TrackerKey, [int]$MaxBodyBytes = 60000) + + $ctx = $Data.metadata + $srBranch = $ctx.srBranch + $shortHead = if ($ctx.srHeadSha) { $ctx.srHeadSha.Substring(0, 8) } else { '?' } + + # Compute verdict + semantic hash (deterministic, used in markers) + $verdict = Get-OverallVerdict -Data $Data + $semanticHash = Get-ReportSemanticHash -Data $Data -Verdict $verdict + + $sb = [System.Text.StringBuilder]::new() + + # === HEADER + MARKERS === + # Markers go FIRST so a workflow scanning for them can short-circuit + # without parsing the body. + if ($TrackerKey) { + [void]$sb.AppendLine("") + } + [void]$sb.AppendLine("") + + $mode = if ($ctx.ContainsKey('mode')) { $ctx['mode'] } else { 'in-flight' } + $inherits = ($ctx.ContainsKey('inheritFromPriorSr') -and $ctx['inheritFromPriorSr']) + if ($mode -eq 'candidate') { + if ($inherits) { + [void]$sb.AppendLine("# Release Readiness — CANDIDATE for next SR (main + inherited from $($ctx.priorSrBranch))") + [void]$sb.AppendLine() + [void]$sb.AppendLine("> 🛫 **Pre-flight mode (cut-then-merge).** Surveying ``$srBranch`` (== main) PLUS commits inherited from prior SR ``$($ctx.priorSrBranch)`` (the SR will be cut from main, then have the prior SR merged into it).") + } else { + [void]$sb.AppendLine("# Release Readiness — CANDIDATE for next SR (vs $($ctx.priorSrBranch))") + [void]$sb.AppendLine() + [void]$sb.AppendLine("> 🛫 **Pre-flight mode.** Surveying ``$srBranch`` (== main) against prior SR ``$($ctx.priorSrBranch)``. Shows what WOULD ship if we cut the next SR today.") + } + } else { + [void]$sb.AppendLine("# Release Readiness — $srBranch") + } + [void]$sb.AppendLine() + + # === VERDICT (always second, always visible) === + [void]$sb.AppendLine("## Verdict — $($verdict.symbol) **$($verdict.label)**") + [void]$sb.AppendLine() + foreach ($r in $verdict.reasons) { + [void]$sb.AppendLine("- $r") + } + [void]$sb.AppendLine() + + # Tracker + provenance line (visible, complements the HTML comment marker) + if ($TrackerKey) { + [void]$sb.AppendLine("**Tracker:** ``$TrackerKey`` · mode=``$mode`` · branch=``$srBranch``") + } + $shaLinked = ConvertTo-LinkedSha -Sha $ctx.srHeadSha -RepoUrl $RepoUrl + [void]$sb.AppendLine("**HEAD**: $shaLinked — $($ctx.srHeadSubject)") + [void]$sb.AppendLine("**Generated**: $($ctx.fetchedAt)") + # Expected ship date — cadence depends on PatchVersion: + # - x0 patches (80, 90…) + previews → 2nd Tuesday of the month + # - hotfix patches (81, 82…) → ASAP, no cadence + # Read patch from the survey ref's Versions.props. In candidate mode srRef + # is main (so we'd see e.g. 90 for upcoming SR9, still 2nd-Tuesday cadence). + # Defensive: $ctx may be a hashtable, PSCustomObject, or test fixture with + # no srRef at all — fall back to 2nd-Tuesday cadence in that case. + $patchForShipDate = $null + $srRefForShipDate = if ($ctx -is [hashtable]) { + if ($ctx.ContainsKey('srRef')) { $ctx['srRef'] } else { $null } + } elseif ($ctx.PSObject.Properties.Name -contains 'srRef') { + $ctx.srRef + } else { $null } + if ($srRefForShipDate) { + $vpForShipDate = Get-VersionsPropsState -Ref $srRefForShipDate + if ($vpForShipDate) { $patchForShipDate = [int]$vpForShipDate.Patch } + } + # Anchor on main-bump date for this SR's cycle, so the date doesn't slide + # into the next SR's window once this SR's calendar month passes. + $mainBumpDateForShip = $null + if ($null -ne $patchForShipDate) { + $cycleBaseForShip = [int]([Math]::Floor($patchForShipDate / 10) * 10) + $majorForShip = $null + if ($vpForShipDate -and $vpForShipDate.Major) { $majorForShip = [int]$vpForShipDate.Major } + $bumpInfoForShip = if ($null -ne $majorForShip) { + Get-MainBumpDateForCycle -CycleBase $cycleBaseForShip -MajorVersion $majorForShip + } else { + Get-MainBumpDateForCycle -CycleBase $cycleBaseForShip + } + if ($bumpInfoForShip) { $mainBumpDateForShip = $bumpInfoForShip.Date } + } + $shipDate = Get-ExpectedShipDate -PatchVersion $patchForShipDate -MainBumpDate $mainBumpDateForShip + if ($shipDate.Cadence -eq 'asap-hotfix') { + [void]$sb.AppendLine("**Expected ship date**: 🚑 $($shipDate.FormattedLong) — $($shipDate.Note)") + } elseif ($shipDate.MissedWindow) { + [void]$sb.AppendLine("**Expected ship date**: ⚠️ $($shipDate.FormattedLong) — **window passed** ($([Math]::Abs($shipDate.DaysFromNow)) day(s) ago). $($shipDate.Note)") + } else { + $whenSuffix = if ($shipDate.DaysFromNow -eq 0) { + '🚨 **shipping today**' + } elseif ($shipDate.DaysFromNow -eq 1) { + '⚠️ tomorrow' + } else { + "in $($shipDate.DaysFromNow) days" + } + [void]$sb.AppendLine("**Expected ship date**: $($shipDate.FormattedLong) — $whenSuffix ($($shipDate.Note))") + } + [void]$sb.AppendLine("**Regression labels**: $($ctx.regressionLabels -join ', ') _(mode: $($ctx.labelInferenceMode))_") + [void]$sb.AppendLine() + + if ($Data.ContainsKey('warnings') -and $Data['warnings'].Count -gt 0) { + [void]$sb.AppendLine("> ⚠️ **Warnings:**") + foreach ($w in $Data['warnings']) { [void]$sb.AppendLine("> - $w") } + [void]$sb.AppendLine() + } + + # === BLOCKING SUMMARY (hoisted to top, right under the verdict) === + # Surface every BLOCKED ship-check AND every Tier 1 regression so the + # release captain sees what's preventing ship without scrolling past + # CI tables, open-PR tables, and the full tier breakdown below. + $blockingItems = New-Object System.Collections.Generic.List[hashtable] + if ($Data.ContainsKey('shipChecks') -and $Data['shipChecks']) { + foreach ($sc in $Data['shipChecks']) { + if ($sc.Status -eq 'BLOCKED') { + [void]$blockingItems.Add(@{ + area = "🛠️ $($sc.Area)" + details = $sc.Details + action = $sc.NextAction + }) + } + } + } + if ($Data.ContainsKey('regressions') -and $Data['regressions']) { + foreach ($r in $Data['regressions']) { + $tier = Get-VerdictTier -Classification $r.classification + if ($r.classification -eq 'no-fix-yet' -and $r.state -ne 'OPEN') { $tier = 3 } + if ($tier -eq 1) { + $issLink = "[#$($r.issue)]($RepoUrl/issues/$($r.issue))" + [void]$blockingItems.Add(@{ + area = "🐞 $issLink — $($r.classification)" + details = $r.title + action = $r.recommendedAction + }) + } + } + } + + if ($blockingItems.Count -gt 0) { + [void]$sb.AppendLine("## 🔴 Blocking — $($blockingItems.Count) item(s)") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Area | Details | Next action |') + [void]$sb.AppendLine('|---|---|---|') + foreach ($b in $blockingItems) { + $area = ($b.area -replace '\|', '\|').Trim() + $details = ($b.details -replace '\|', '\|').Trim() + $action = ($b.action -replace '\|', '\|').Trim() + [void]$sb.AppendLine("| $area | $details | $action |") + } + [void]$sb.AppendLine() + } else { + [void]$sb.AppendLine("## 🟢 No blocking items") + [void]$sb.AppendLine() + } + + # === CLEANUP FOLLOW-UPS (hoisted under the blocking summary) === + # CLEANUP-status ship checks are real follow-ups (stale milestones, missing + # bug-template entries) that should get done but don't prevent shipping. + # Surface them prominently so they don't get lost, but keep them separate + # from the 🔴 Blocking table — the release captain shouldn't have to wade + # past housekeeping to find the actual ship blockers. + $cleanupItems = New-Object System.Collections.Generic.List[hashtable] + if ($Data.ContainsKey('shipChecks') -and $Data['shipChecks']) { + foreach ($sc in $Data['shipChecks']) { + if ($sc.Status -eq 'CLEANUP') { + [void]$cleanupItems.Add(@{ + area = "🧹 $($sc.Area)" + details = $sc.Details + action = $sc.NextAction + }) + } + } + } + if ($cleanupItems.Count -gt 0) { + [void]$sb.AppendLine("## 🧹 Cleanup follow-ups — $($cleanupItems.Count) item(s)") + [void]$sb.AppendLine() + [void]$sb.AppendLine("_These are housekeeping items that should be addressed but do NOT block this release._") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Area | Details | Next action |') + [void]$sb.AppendLine('|---|---|---|') + foreach ($c in $cleanupItems) { + $area = ($c.area -replace '\|', '\|').Trim() + $details = ($c.details -replace '\|', '\|').Trim() + $action = ($c.action -replace '\|', '\|').Trim() + [void]$sb.AppendLine("| $area | $details | $action |") + } + [void]$sb.AppendLine() + } + + # === Recent CI Failure Scanner signals (hoisted near the top so signals + # specific to this release branch are surfaced before deeper + # readiness / SR contents / regression analysis) === + if ($Data.ContainsKey('ciScanIssues')) { + $ciScanBranch = $ctx.srBranch + $ciScanFilteredOut = if ($Data.ContainsKey('ciScanFilteredOut')) { [int]$Data['ciScanFilteredOut'] } else { 0 } + $ciScanIssuesData = @($Data['ciScanIssues']) + [void]$sb.AppendLine("## Recent CI Failure Scanner signals (``ci-scan``)") + [void]$sb.AppendLine() + $blurb = "_Filtered to issues whose ``**Branch**: `` body marker matches ``$ciScanBranch`` (auto-filed by the CI Failure Scanner workflow every 12h). Fresh issues (<24h) are flagged 🆕._" + if ($ciScanFilteredOut -gt 0) { + $blurb += " _$ciScanFilteredOut other-branch issue(s) were excluded as not relevant to this SR._" + } + [void]$sb.AppendLine($blurb) + [void]$sb.AppendLine() + if ($ciScanIssuesData.Count -gt 0) { + $rows = Format-CiScanIssueRows -Issues $ciScanIssuesData -RepoUrl $RepoUrl + if ($rows) { + [void]$sb.Append($rows) + } else { + [void]$sb.AppendLine("_No ci-scan issues target ``$ciScanBranch``._") + } + } else { + [void]$sb.AppendLine("_No ci-scan issues target ``$ciScanBranch``._") + } + [void]$sb.AppendLine() + } + + # === OPEN FIX PRs INBOUND (hoisted high — actionable intelligence) === + # Regression issues whose fix is in flight as an open PR (either against main + # awaiting merge, or already targeting SR as a backport). These deserve more + # visibility than buried in Tier 2 — they're the pre-backport pipeline the + # release captain needs to watch. + if ($Data.ContainsKey('regressions') -and $Data['regressions']) { + $openFixRows = New-Object System.Collections.Generic.List[hashtable] + foreach ($r in $Data['regressions']) { + if ($r.classification -ne 'open-on-main' -and $r.classification -ne 'backport-in-progress') { continue } + if (-not $r.candidateFixPrs -or $r.candidateFixPrs.Count -eq 0) { continue } + + $issLink = "[#$($r.issue)]($RepoUrl/issues/$($r.issue))" + $titleShort = if ($r.title.Length -gt 70) { $r.title.Substring(0, 70) + '...' } else { $r.title } + $issCell = "$issLink — $titleShort" + + if ($r.classification -eq 'backport-in-progress') { + # The open backport PR targets the SR branch directly — pick the + # first OPEN one from the candidate fix PR's backports array. + foreach ($cp in $r.candidateFixPrs) { + # Hashtables expose ContainsKey; PSCustomObjects expose .PSObject.Properties. + # Test both since candidateFixPrs records can be either shape. + $hasBackports = $false + if ($cp -is [hashtable] -or $cp -is [System.Collections.IDictionary]) { + $hasBackports = $cp.ContainsKey('backports') -and $cp['backports'] + } elseif ($cp.PSObject.Properties['backports']) { + $hasBackports = [bool]$cp.backports + } + if (-not $hasBackports) { continue } + $openBp = $cp.backports | Where-Object { $_.state -eq 'OPEN' } | Select-Object -First 1 + if ($openBp) { + $prLink = "[#$($openBp.number)]($RepoUrl/pull/$($openBp.number))" + [void]$openFixRows.Add(@{ + prCell = $prLink + baseCell = "``$srBranch``" + issCell = $issCell + statusCell = "🟡 backport OPEN on SR" + actionCell = 'Land this PR before ship' + }) + break + } + } + } else { + # open-on-main: fix PR is OPEN against main (or another non-SR base). + # Pick the first candidate PR whose state is OPEN. + $openMain = $r.candidateFixPrs | Where-Object { $_.state -eq 'OPEN' } | Select-Object -First 1 + if ($openMain) { + $prLink = "[#$($openMain.number)]($RepoUrl/pull/$($openMain.number))" + $base = if ($openMain.baseRef) { "``$($openMain.baseRef)``" } else { '`main`' } + [void]$openFixRows.Add(@{ + prCell = $prLink + baseCell = $base + issCell = $issCell + statusCell = '🔵 OPEN — awaiting main merge' + actionCell = 'Watch for merge, then open backport to SR' + }) + } + } + } + + if ($openFixRows.Count -gt 0) { + [void]$sb.AppendLine("## 📥 Open Fix PRs Inbound — $($openFixRows.Count) PR(s)") + [void]$sb.AppendLine() + [void]$sb.AppendLine('_Fix PRs in flight for regression issues. Land these (or their backports) before ship to close out the regression list._') + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Fix PR | Base | Regression issue | Status | Next action |') + [void]$sb.AppendLine('|---|---|---|---|---|') + foreach ($row in $openFixRows) { + $prCell = ($row.prCell -replace '\|', '\|').Trim() + $baseCell = ($row.baseCell -replace '\|', '\|').Trim() + $issCell = ($row.issCell -replace '\|', '\|').Trim() + $statCell = ($row.statusCell -replace '\|', '\|').Trim() + $actCell = ($row.actionCell -replace '\|', '\|').Trim() + [void]$sb.AppendLine("| $prCell | $baseCell | $issCell | $statCell | $actCell |") + } + [void]$sb.AppendLine() + } + } + + # === SHIP-READINESS CHECKS (full table — non-blocking + blocking) === + if ($Data.ContainsKey('shipChecks') -and $Data['shipChecks'] -and $Data['shipChecks'].Count -gt 0) { + [void]$sb.AppendLine("## Ship-readiness checks") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Check | Status | Details | Next action |') + [void]$sb.AppendLine('|---|---|---|---|') + foreach ($sc in $Data['shipChecks']) { + $statusEmoji = switch ($sc.Status) { + 'READY' { '🟢 READY' } + 'WATCH' { '🟡 WATCH' } + 'BLOCKED' { '🔴 BLOCKED' } + 'CLEANUP' { '🧹 CLEANUP' } + default { "⚪ $($sc.Status)" } + } + $area = ($sc.Area -replace '\|', '\|').Trim() + $details = ($sc.Details -replace '\|', '\|').Trim() + $action = ($sc.NextAction -replace '\|', '\|').Trim() + [void]$sb.AppendLine("| $area | $statusEmoji | $details | $action |") + } + [void]$sb.AppendLine() + } + + # === HUMAN-EDITABLE SECTION === + # Wrapped in begin/end markers so a workflow can preserve manual edits + # across re-runs (idempotency). Built as a reusable block so the body-size + # cap below can guarantee the markers survive truncation — a truncated body + # that lost them would let the daily refresh overwrite live Release Captain + # Notes (the markers sit mid-body, below potentially unbounded sections). + $notesSb = [System.Text.StringBuilder]::new() + [void]$notesSb.AppendLine("") + [void]$notesSb.AppendLine("## Release Captain Notes") + [void]$notesSb.AppendLine() + [void]$notesSb.AppendLine("_Add manual notes here. Anything between these begin/end markers is preserved across automated re-runs._") + [void]$notesSb.AppendLine("") + $notesBlockText = $notesSb.ToString() + [void]$sb.Append($notesBlockText) + [void]$sb.AppendLine() + + # === CI section === + if ($Data.ContainsKey('ci') -and $Data['ci']) { + $ciData = $Data['ci'] + [void]$sb.AppendLine("## CI Status — overall: ``$($ciData.overall)``") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Pipeline | Verdict | Latest result | At/ahead of SR HEAD? | Build |') + [void]$sb.AppendLine('|---|---|---|---|---|') + foreach ($p in $ciData.pipelines) { + $lb = $p.latestBuild + $pverdict = $p.verdict + $result = if ($lb -and $lb.result) { $lb.result } elseif ($lb -and $lb.status -in @('inProgress','notStarted')) { "_$($lb.status)_" } else { '—' } + $fresh = if ($lb) { if ($lb.isAtOrAheadOfSrHead) { '✅' } else { '❌ stale' } } else { '—' } + $buildLink = if ($lb -and $lb.url) { "[$($lb.id)]($($lb.url))" } else { '—' } + [void]$sb.AppendLine("| $($p.name) | ``$pverdict`` | $result | $fresh | $buildLink |") + } + [void]$sb.AppendLine() + } + + # === Recent CI Failure Scanner signals: hoisted to top, see earlier block === + + # === SR contents section === + if ($Data.ContainsKey('srContents') -and $Data['srContents']) { + $sc = $Data['srContents'] + [void]$sb.AppendLine("## What's New in SR — $($sc.commitCount) commits") + [void]$sb.AppendLine() + if ($inherits -and $sc.ContainsKey('inheritedCommitCount') -and $sc['inheritedCommitCount'] -gt 0) { + [void]$sb.AppendLine("- **From main** (since prior SR): $($sc.primaryCommitCount) commits / $($sc.primarySourcePrs.Count) source PRs") + [void]$sb.AppendLine("- **Inherited from $($ctx.priorSrBranch)** (will be merged in after cut): $($sc.inheritedCommitCount) commits / $($sc.inheritedSourcePrs.Count) source PRs") + [void]$sb.AppendLine("- **Total source PRs** (deduplicated): **$($sc.sourcePrs.Count)** (see ``sr-source-prs.txt``)") + } else { + [void]$sb.AppendLine("- Source PRs included: **$($sc.sourcePrs.Count)** (see ``sr-source-prs.txt``)") + } + [void]$sb.AppendLine("- Reverts detected: **$($sc.reverts.Count)**") + if ($sc.reverts.Count -gt 0) { + [void]$sb.AppendLine() + [void]$sb.AppendLine('### Reverts') + [void]$sb.AppendLine('| Revert commit | Reverts PR | Reverts commit | On |') + [void]$sb.AppendLine('|---|---|---|---|') + foreach ($r in $sc.reverts) { + $rs = ConvertTo-LinkedSha -Sha $r.revertCommit -RepoUrl $RepoUrl + $rc = ConvertTo-LinkedSha -Sha $r.revertsCommit -RepoUrl $RepoUrl + $rp = ConvertTo-LinkedPr -PrNumber $r.revertsPr -RepoUrl $RepoUrl + $ro = if ($r.ContainsKey('origin')) { $r.origin } else { '?' } + [void]$sb.AppendLine("| $rs | $rp | $rc | $ro |") + } + } + [void]$sb.AppendLine() + } + + # === Open SR-targeting PRs === + # + # Two modes: + # - Live SR (mode != 'candidate'): show the full table — these are real + # backport PRs targeting the SR branch, which is a small, useful set. + # - Candidate (mode == 'candidate'): srBranch is main, so this query + # returns 100+ open PRs targeting main — far too noisy for a tracker + # issue. Instead, surface only the dotnet/maui "candidate PR" if one + # exists (e.g. "June 8th, Candidate" — the PR that promotes a specific + # main commit as the basis for cutting the next SR). + if ($Data.ContainsKey('openSrPrs') -and $Data['openSrPrs'] -and $Data['openSrPrs'].Count -gt 0) { + if ($mode -eq 'candidate') { + # Find a PR whose title looks like a candidate-promotion PR. + # Be conservative — require a word boundary so "CandidateView" doesn't match. + $candidatePrs = @($Data['openSrPrs'] | Where-Object { + $_.title -match '(?i)\bcandidate\b' + }) + [void]$sb.AppendLine("## Candidate PR for next SR cut") + [void]$sb.AppendLine() + if ($candidatePrs.Count -eq 0) { + [void]$sb.AppendLine("_No open PR titled `*Candidate*` found targeting ``$srBranch``. Open one when ready to promote a main commit as the SR cut point._") + } else { + foreach ($cp in $candidatePrs) { + $cpLink = ConvertTo-LinkedPr -PrNumber $cp.number -RepoUrl $RepoUrl + $cpTitle = if ($cp.title.Length -gt 80) { $cp.title.Substring(0, 80) + '...' } else { $cp.title } + [void]$sb.AppendLine("- $cpLink — $cpTitle (by $(Format-GitHubHandle $cp.author.login), updated $($cp.updatedAt))") + } + [void]$sb.AppendLine() + [void]$sb.AppendLine("_Full list of $($Data['openSrPrs'].Count) open PRs targeting ``$srBranch`` omitted to reduce noise; see [the PR list]($RepoUrl/pulls?q=is%3Apr+is%3Aopen+base%3A$srBranch)._") + } + [void]$sb.AppendLine() + } else { + [void]$sb.AppendLine("## Open PRs Targeting $srBranch — $($Data['openSrPrs'].Count)") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| PR | Title | Author | Draft? | Review | Updated |') + [void]$sb.AppendLine('|---|---|---|---|---|---|') + foreach ($pr in $Data['openSrPrs']) { + $title = if ($pr.title.Length -gt 60) { $pr.title.Substring(0, 60) + '...' } else { $pr.title } + $draft = if ($pr.isDraft) { '✏️' } else { '' } + $rev = if ($pr.reviewDecision) { $pr.reviewDecision } else { '—' } + $prLink = ConvertTo-LinkedPr -PrNumber $pr.number -RepoUrl $RepoUrl + [void]$sb.AppendLine("| $prLink | $title | $(Format-GitHubHandle $pr.author.login) | $draft | $rev | $($pr.updatedAt) |") + } + [void]$sb.AppendLine() + } + } + + # === Regressions section — organized into tiers === + if ($Data.ContainsKey('regressions') -and $Data['regressions']) { + $regs = $Data['regressions'] + $summary = if ($Data.ContainsKey('summary')) { $Data['summary'] } else { @{} } + + [void]$sb.AppendLine("## Regression Candidates — $($regs.Count) issues scanned") + [void]$sb.AppendLine() + [void]$sb.AppendLine('### Summary') + [void]$sb.AppendLine('| Verdict | Count |') + [void]$sb.AppendLine('|---|---|') + foreach ($k in $summary.Keys | Sort-Object) { + [void]$sb.AppendLine("| ``$k`` | $($summary[$k]) |") + } + [void]$sb.AppendLine() + + # Three deterministic tiers. Order within a tier is alphabetical + # over the classification name for stable diffs across runs. + $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 + + $emitTier = { + param([string]$Header, [string[]]$Classes, [string]$EmptyLine) + $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 + if ($cls -eq 'no-fix-yet') { + $items = @($items | Where-Object { $_.state -eq 'OPEN' }) + } + if ($items.Count -eq 0) { continue } + if (-not $any) { + [void]$sb.AppendLine("### $Header") + [void]$sb.AppendLine() + $any = $true + } + [void]$sb.AppendLine("#### ``$cls`` ($($items.Count))") + [void]$sb.AppendLine() + [void]$sb.AppendLine('| Issue | Title | Fix PRs | Action |') + [void]$sb.AppendLine('|---|---|---|---|') + # Stable sort: by issue number ascending + foreach ($it in ($items | Sort-Object issue)) { + $title = if ($it.title.Length -gt 50) { $it.title.Substring(0, 50) + '...' } else { $it.title } + $prList = @($it.candidateFixPrs | ForEach-Object { ConvertTo-LinkedPr -PrNumber $_.number -RepoUrl $RepoUrl }) -join ', ' + if (-not $prList) { $prList = '—' } + $issueLink = if ($RepoUrl) { "[#$($it.issue)]($RepoUrl/issues/$($it.issue))" } else { "#$($it.issue)" } + [void]$sb.AppendLine("| $issueLink | $title | $prList | $($it.recommendedAction) |") + } + [void]$sb.AppendLine() + } + if (-not $any -and $EmptyLine) { + [void]$sb.AppendLine("### $Header") + [void]$sb.AppendLine() + [void]$sb.AppendLine($EmptyLine) + [void]$sb.AppendLine() + } + } + + & $emitTier '🔴 Tier 1 — Blocking' $tier1Classes '_No blocking regressions._' + & $emitTier '🟡 Tier 2 — Risk / Review' $tier2Classes '_No risk-tier regressions._' + & $emitTier '🟢 Tier 3 — Informational' $tier3Classes $null + } + + $body = $sb.ToString() + + # === SAFETY NET: defang any bare @-mentions === + # Primary defense is Format-GitHubHandle at emit time, but PR/issue + # titles or commit messages can contain raw `@user` references that + # would notify real users every time this report is filed. Wrap any + # `@handle` in backticks so GitHub renders it as a code span (no mention). + $body = [regex]::Replace( + $body, + '(^|[^a-zA-Z0-9/`])@([a-zA-Z0-9][a-zA-Z0-9_-]*(?:/[a-zA-Z0-9][a-zA-Z0-9_-]*)?)', + '$1`$2`' + ) + + # === BODY-SIZE CAP === + # GitHub issue body limit is 65,536 bytes. Cap below that and append a + # truncation message. We measure UTF-8 bytes, not character count. + # + # The human-notes block must SURVIVE truncation: it sits mid-body, below + # sections (ci-scan signals, inbound fix PRs, ship-readiness table) that can + # grow unbounded on a busy SR. A blind byte-prefix cut could drop the + # begin/end markers, and the daily refresh would then use a markerless body + # to OVERWRITE the live issue — wiping any Release Captain Notes the team + # added. So we strip the placeholder, truncate only the remaining content + # (reserving room for the notes block + message), then re-append the block. + # This guarantees exactly one clean begin/end pair always survives for the + # workflow splice. The placeholder carries no human data (real notes live on + # the issue), so removing/re-adding it is lossless. The top-of-body hash and + # tracker markers are well within the reserved prefix, so they survive too. + $bytes = [System.Text.Encoding]::UTF8.GetByteCount($body) + if ($bytes -gt $MaxBodyBytes) { + $truncateMsg = "`n`n> ⚠️ **Report truncated** ($bytes bytes exceeded cap of $MaxBodyBytes). See full data in workflow artifacts.`n" + $tail = [System.Text.Encoding]::UTF8.GetByteCount($truncateMsg) + $notesTail = "`n" + $notesBlockText + $notesReserve = [System.Text.Encoding]::UTF8.GetByteCount($notesTail) + $bodyNoNotes = $body.Replace($notesBlockText, '') + $targetLen = $MaxBodyBytes - $tail - $notesReserve + if ($targetLen -lt 0) { $targetLen = 0 } + # Walk back to a safe character boundary + $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($bodyNoNotes) + if ($targetLen -gt $bodyBytes.Length) { $targetLen = $bodyBytes.Length } + $truncatedBytes = New-Object byte[] $targetLen + [Array]::Copy($bodyBytes, 0, $truncatedBytes, 0, $targetLen) + # UTF-8 boundary repair: if the cut landed inside a multi-byte sequence, + # drop the trailing INCOMPLETE sequence. Walk back over continuation + # bytes (10xxxxxx) to the lead byte, infer the sequence length from the + # lead, and cut at the lead only when the full sequence doesn't fit. A + # naive "trim continuation bytes" loop is wrong twice over: it leaves an + # orphan lead byte (e.g. a lone 0xF0) AND it strips a COMPLETE trailing + # multibyte char down to its lead. Either case makes GetString() emit a + # U+FFFD replacement char, which re-encodes to 3 bytes and can push the + # body back over $MaxBodyBytes. + if ($truncatedBytes.Length -gt 0) { + $i = $truncatedBytes.Length - 1 + while ($i -ge 0 -and ($truncatedBytes[$i] -band 0xC0) -eq 0x80) { $i-- } + if ($i -ge 0) { + $lead = $truncatedBytes[$i] + $seqLen = if (($lead -band 0x80) -eq 0x00) { 1 } + elseif (($lead -band 0xE0) -eq 0xC0) { 2 } + elseif (($lead -band 0xF0) -eq 0xE0) { 3 } + elseif (($lead -band 0xF8) -eq 0xF0) { 4 } + else { 1 } + if (($i + $seqLen) -gt $truncatedBytes.Length) { + $newArr = New-Object byte[] $i + [Array]::Copy($truncatedBytes, 0, $newArr, 0, $i) + $truncatedBytes = $newArr + } + } + } + $body = [System.Text.Encoding]::UTF8.GetString($truncatedBytes) + $notesTail + $truncateMsg + } + + return $body +} + +# region ────────────────────── 8. ORCHESTRATOR ──────────────────────────── + +function Invoke-Main { + $excludes = $ExcludeBranches -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + $ctx = Resolve-Context -SrBranch $SrBranch -Repo $Repo -MainBranch $MainBranch ` + -ExcludeBranches $excludes -NoFetch:$NoFetch -Candidate:$Candidate ` + -InheritFromPriorSr:$InheritFromPriorSr + + # Resolve regression labels + $labelMode = 'explicit' + $labelInfo = $null + if ($RegressionLabels) { + $labels = @($RegressionLabels -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } elseif ($InferRegressionLabels) { + $labelInfo = Get-RegressionLabelsAuto -Ctx $ctx + $labels = @($labelInfo.labels) + $labelMode = "inferred ($($labelInfo.confidence))" + if ($labels.Count -eq 0) { + Write-Warn "Label inference produced no labels: $($labelInfo.error)" + } else { + Write-Host "Inferred regression labels: $($labels -join ', ')" -ForegroundColor Yellow + Write-Host " Confidence: $($labelInfo.confidence) — agent should confirm with user" -ForegroundColor Yellow + } + } else { + # No labels, no inference: regressions phase is skipped silently + $labels = @() + } + + $ctx['regressionLabels'] = $labels + $ctx['labelInferenceMode'] = $labelMode + + $data = @{ + metadata = $ctx + warnings = @() + } + + if ($Phase -in 'all', 'commits', 'regressions') { + $srContents = Get-SrCommits -Ctx $ctx + $data['srContents'] = $srContents + } + + if ($Phase -in 'all', 'ci') { + $data['ci'] = Get-CIStatus -Ctx $ctx + } + + if ($Phase -in 'all', 'open-prs') { + $data['openSrPrs'] = Get-OpenSrPrs -Ctx $ctx + } + + # Run version + bug-template checks (cheap; included in all phases except 'ci'-only). + # These surface the "is versions.props bumped?" and "is the bug template updated?" + # questions as blocking items at the top of the report. + if ($Phase -in 'all', 'commits', 'regressions', 'open-prs') { + $data['shipChecks'] = Get-ReleaseShipChecks -Ctx $ctx + } + + # CI scanner + KBE issue signals — merged into shipChecks so they appear in the + # ship-readiness table AND can escalate the verdict (fresh ci-scan → WATCH; never + # BLOCKED automatically because the scanner can be noisy). + if ($Phase -in 'all', 'commits', 'regressions', 'open-prs') { + # Scope ci-scan to the branch we're surveying so other-branch noise + # (e.g. main CI signals on an in-flight SR report) doesn't bleed in. + # ctx.srBranch is automatically: main/$MainBranch in candidate mode + # (when no SR has been cut yet) or the actual SR branch in in-flight mode. + $signalResult = Get-CiSignalChecks -Branch $ctx.srBranch + if (-not $data.ContainsKey('shipChecks') -or -not $data['shipChecks']) { + $data['shipChecks'] = @() + } + $data['shipChecks'] = @($data['shipChecks']) + @($signalResult.Checks) + $data['ciScanIssues'] = @($signalResult.CiScanIssues) + $data['ciScanFilteredOut'] = $signalResult.CiScanFilteredOut + $data['kbeIssues'] = @($signalResult.KbeIssues) + } + + # Maestro/BAR operational checks — verify the SR branch is wired into BAR's + # default-channel mappings and the SR HEAD commit has a published build. + # Runs via `darc` CLI; falls back to UNKNOWN with verification commands when + # darc isn't available (CI environments without the tool installed). Append + # to shipChecks so BLOCKED results escalate the verdict the same way. + if ($Phase -in 'all', 'commits', 'regressions', 'open-prs') { + $maestroChecks = Get-MaestroOperationalChecks -Ctx $ctx -SkipChecks:$SkipMaestroChecks + if ($maestroChecks -and $maestroChecks.Count -gt 0) { + if (-not $data.ContainsKey('shipChecks') -or -not $data['shipChecks']) { + $data['shipChecks'] = @() + } + $data['shipChecks'] = @($data['shipChecks']) + @($maestroChecks) + } + } + + # Milestone hygiene checks — confirm the current cycle's milestone exists, + # the next cycle's milestone has been pre-created, and no past-due milestones + # are still open from already-shipped releases. Uses gh API (always available + # in CI), so no UNKNOWN fallback needed beyond the per-call try/catch. + if ($Phase -in 'all', 'commits', 'regressions', 'open-prs') { + $milestoneChecks = Get-MilestoneHygieneChecks -Ctx $ctx -SkipChecks:$SkipMilestoneChecks + if ($milestoneChecks -and $milestoneChecks.Count -gt 0) { + if (-not $data.ContainsKey('shipChecks') -or -not $data['shipChecks']) { + $data['shipChecks'] = @() + } + $data['shipChecks'] = @($data['shipChecks']) + @($milestoneChecks) + } + } + + # Candidate-PR check (candidate mode only) — surface the open PR that + # promotes a specific main commit as the SR cut point. Most important + # PR in the cycle: SR can't be cut until it merges. Renders as a WATCH + # check in the ship-readiness table. + if ($Phase -in 'all', 'commits', 'regressions', 'open-prs') { + $candidateChecks = Get-CandidatePrChecks -Ctx $ctx + if ($candidateChecks -and $candidateChecks.Count -gt 0) { + if (-not $data.ContainsKey('shipChecks') -or -not $data['shipChecks']) { + $data['shipChecks'] = @() + } + $data['shipChecks'] = @($data['shipChecks']) + @($candidateChecks) + } + } + + if ($Phase -in 'all', 'regressions') { + if ($labels.Count -eq 0) { + Write-Warn "No regression labels provided/inferred; skipping regressions phase. Pass -RegressionLabels or -InferRegressionLabels." + $data['regressions'] = @() + } else { + $data['regressions'] = Get-RegressionCandidates -Ctx $ctx -Labels $labels ` + -SrContents $data['srContents'] -MaxIssues $MaxIssues + + # Summary buckets + $summary = @{} + foreach ($r in $data['regressions']) { + $k = $r.classification + if (-not $summary.ContainsKey($k)) { $summary[$k] = 0 } + $summary[$k] += 1 + } + $data['summary'] = $summary + } + } + + $data['warnings'] = @($Script:Warnings) + + # Compute deterministic verdict + semantic hash. Surfaced in JSON so + # automation can consume it without re-parsing the markdown. + $verdict = Get-OverallVerdict -Data $data + $semanticHash = Get-ReportSemanticHash -Data $data -Verdict $verdict + $data['verdict'] = @{ + symbol = $verdict.symbol + tier = $verdict.tier + label = $verdict.label + reasons = $verdict.reasons + } + $data['semanticHash'] = $semanticHash + # Expected ship date — surfaced in JSON so downstream automation doesn't + # repeat the cadence math. ASAP hotfixes return null date + cadence='asap-hotfix'. + $metaForJson = $data.metadata + $srRefForJson = if ($metaForJson -is [hashtable]) { + if ($metaForJson.ContainsKey('srRef')) { $metaForJson['srRef'] } else { $null } + } elseif ($metaForJson.PSObject.Properties.Name -contains 'srRef') { + $metaForJson.srRef + } else { $null } + $patchForJson = $null + if ($srRefForJson) { + $vpForJson = Get-VersionsPropsState -Ref $srRefForJson + if ($vpForJson) { $patchForJson = [int]$vpForJson.Patch } + } + $mainBumpDateForJson = $null + $mainBumpShaForJson = $null + if ($null -ne $patchForJson) { + $cycleBaseForJson = [int]([Math]::Floor($patchForJson / 10) * 10) + $majorForJson = $null + if ($vpForJson -and $vpForJson.Major) { $majorForJson = [int]$vpForJson.Major } + $bumpInfoForJson = if ($null -ne $majorForJson) { + Get-MainBumpDateForCycle -CycleBase $cycleBaseForJson -MajorVersion $majorForJson + } else { + Get-MainBumpDateForCycle -CycleBase $cycleBaseForJson + } + if ($bumpInfoForJson) { + $mainBumpDateForJson = $bumpInfoForJson.Date + $mainBumpShaForJson = $bumpInfoForJson.Sha + } + } + $shipDateInfo = Get-ExpectedShipDate -PatchVersion $patchForJson -MainBumpDate $mainBumpDateForJson + $data['expectedShipDate'] = @{ + cadence = $shipDateInfo.Cadence + date = if ($shipDateInfo.Date) { $shipDateInfo.Date.ToString('yyyy-MM-dd') } else { $null } + daysFromNow = $shipDateInfo.DaysFromNow + formattedLong = $shipDateInfo.FormattedLong + note = $shipDateInfo.Note + patchVersion = $patchForJson + missedWindow = $shipDateInfo.MissedWindow + anchorSource = $shipDateInfo.AnchorSource + mainBumpDate = if ($mainBumpDateForJson) { $mainBumpDateForJson.ToString('yyyy-MM-dd') } else { $null } + mainBumpSha = $mainBumpShaForJson + } + if ($TrackerKey) { + $data['trackerKey'] = $TrackerKey + } + + # Output + $jsonOut = $data | ConvertTo-Json -Depth 20 -Compress:$false + $mdOut = Format-MarkdownReport -Data $data -RepoUrl $RepoUrl -TrackerKey $TrackerKey -MaxBodyBytes $MaxBodyBytes + + if ($OutputDir) { + if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir | Out-Null } + if ($OutputFormat -in 'json', 'both') { + Set-Content -Path (Join-Path $OutputDir 'release-readiness.json') -Value $jsonOut -Encoding UTF8 + } + if ($OutputFormat -in 'markdown', 'both') { + Set-Content -Path (Join-Path $OutputDir 'release-readiness.md') -Value $mdOut -Encoding UTF8 + } + if ($data.ContainsKey('srContents')) { + $srcPrs = $data['srContents'].sourcePrs -join "`n" + Set-Content -Path (Join-Path $OutputDir 'sr-source-prs.txt') -Value $srcPrs -Encoding UTF8 + + $commitsJson = $data['srContents'] | ConvertTo-Json -Depth 10 + Set-Content -Path (Join-Path $OutputDir 'sr-commits.json') -Value $commitsJson -Encoding UTF8 + } + Write-Host "`nWrote outputs to: $OutputDir" -ForegroundColor Green + Get-ChildItem $OutputDir | ForEach-Object { Write-Host " $($_.Name) ($($_.Length) bytes)" } + } else { + if ($OutputFormat -in 'json', 'both') { Write-Output $jsonOut } + if ($OutputFormat -in 'markdown', 'both') { Write-Output $mdOut } + } +} + +# Skip orchestration when dot-sourced for unit tests. Tests do: +# $env:GET_RELEASE_READINESS_TEST_MODE = '1' +# . path/to/Get-ReleaseReadiness.ps1 +# which makes Invoke-Main a no-op while still loading all functions. +if (-not $env:GET_RELEASE_READINESS_TEST_MODE) { + Invoke-Main +} diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 new file mode 100644 index 000000000000..7b560d6f645a --- /dev/null +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -0,0 +1,2641 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 +<# +.SYNOPSIS + Smoke tests for Get-ReleaseReadiness.ps1. + +.DESCRIPTION + Tests two flavors: + (a) Parser/regex unit tests with fake commit-message fixtures (no network) + (b) End-to-end smoke against SR7 known-answer set (requires git + gh) + + Run with -SkipE2E to skip the network-dependent integration test. +#> +[CmdletBinding()] +param( + [switch]$SkipE2E +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$script:passed = 0 +$script:failed = 0 + +function Assert-Eq { + param([string]$Label, $Expected, $Actual) + if ($Expected -ceq $Actual -or + ((@($Expected) -join ',') -eq (@($Actual) -join ','))) { + Write-Host " ✅ $Label" -ForegroundColor Green + $script:passed++ + } else { + Write-Host " ❌ $Label" -ForegroundColor Red + Write-Host " expected: $Expected" -ForegroundColor DarkRed + Write-Host " actual : $Actual" -ForegroundColor DarkRed + $script:failed++ + } +} + +# ─────────── Parser/regex unit tests (no network) ─────────── + +Write-Host "`n[Unit] Commit message parsing" -ForegroundColor Cyan + +# Test 1: Backport with "(#NNNN)" subject suffix and "Backport of #NNNN" body +$bodyA = @" +Backport of #35356 + +This is the backport of the Android CollectionView fix. +Fixes #35313 + +(cherry picked from commit deadbeef1234) +"@ +$subjA = '[release/10.0.1xx-sr7] [Android] Fix CollectionView ScrollTo(0) IsGrouped (#35428)' +$subjMatch = [regex]::Matches($subjA, '\(#(\d+)\)') +Assert-Eq -Label "Subject extracts backport PR #" -Expected '35428' -Actual $subjMatch[$subjMatch.Count - 1].Groups[1].Value + +$sourceMatch = [regex]::Match($bodyA, '(?im)(?:backport\s+of|cherry[-\s]picked\s+from(?:\s+PR)?)\s+#(\d+)') +Assert-Eq -Label "Body extracts source PR via 'Backport of #'" -Expected '35356' -Actual $sourceMatch.Groups[1].Value + +$cherrySha = [regex]::Match($bodyA, '(?im)cherry\s+picked\s+from\s+commit\s+([0-9a-f]{7,40})') +Assert-Eq -Label "Body extracts cherry-pick source SHA" -Expected 'deadbeef1234' -Actual $cherrySha.Groups[1].Value + +$issMatches = [regex]::Matches($bodyA, '(?im)(?:fixes|closes|resolves)\s+(?:dotnet/maui#|#)(\d+)') +Assert-Eq -Label "Body extracts 'Fixes #' issues" -Expected '35313' -Actual $issMatches[0].Groups[1].Value + +# Test 2: Revert commit detection +$subjRevert = '[release/10.0.1xx-sr7] Revert - Fix Changing Shell.NavBarIsVisible does not update (#35703)' +$bodyRevert = @" +This reverts commit abc1234def5678. +"@ +$isRevert = ($subjRevert -match '(?i)^(?:\[[^\]]+\]\s+)?Revert\b') -or ($subjRevert -match '\[Revert\]') +Assert-Eq -Label "Detect Revert after [branch-prefix]" -Expected $true -Actual $isRevert + +$revertedSha = [regex]::Match($bodyRevert, '(?im)This reverts commit\s+([0-9a-f]{7,40})') +Assert-Eq -Label "Extract reverted commit SHA" -Expected 'abc1234def5678' -Actual $revertedSha.Groups[1].Value + +# Test 3: Plain "Revert " prefix +$subjRevertPlain = 'Revert "Fix some thing" (#35744)' +$isRevertPlain = ($subjRevertPlain -match '(?i)^(?:\[[^\]]+\]\s+)?Revert\b') -or ($subjRevertPlain -match '\[Revert\]') +Assert-Eq -Label "Detect 'Revert ' prefix" -Expected $true -Actual $isRevertPlain + +# Test 3b: Bracketed [Revert] prefix +$subjBracketRevert = '[Revert] - [Windows] Fix WebView blank rendering (#35744)' +$isBracketRevert = ($subjBracketRevert -match '(?i)^(?:\[[^\]]+\]\s+)?Revert\b') -or ($subjBracketRevert -match '\[Revert\]') +Assert-Eq -Label "Detect '[Revert]' bracket form" -Expected $true -Actual $isBracketRevert + +# Test 4: Non-fix PR body should not match closing-keyword +$nonFix = 'Adds a helper method. Mentions #12345 in passing.' +$closingMatch = $nonFix -match "(?im)(?:fixes|closes|resolves)\s+(?:dotnet/maui#|#)12345\b" +Assert-Eq -Label "Plain mention does not trigger closing-keyword" -Expected $false -Actual $closingMatch + +# Test 5: Closing keyword case-insensitive + with "Fixes dotnet/maui#NNNN" +$crossRepoFix = 'Fixes dotnet/maui#9999' +$crFixMatch = $crossRepoFix -match "(?im)(?:fixes|closes|resolves)\s+(?:dotnet/maui#|#)9999\b" +Assert-Eq -Label "Cross-repo 'Fixes dotnet/maui#NNNN' matches" -Expected $true -Actual $crFixMatch + +# Test 6: SR branch name parsing for label inference +$branchTest = 'release/10.0.1xx-sr7' +$brMatch = [regex]::Match($branchTest, '^release/(\d+)\.(\d+)\.\d+xx-sr(\d+)$') +Assert-Eq -Label "SR branch parses major.minor.sr#" -Expected '10,0,7' ` + -Actual "$($brMatch.Groups[1].Value),$($brMatch.Groups[2].Value),$($brMatch.Groups[3].Value)" + +$badBranch = 'release/main-sr1-preview' +$badMatch = [regex]::Match($badBranch, '^release/(\d+)\.(\d+)\.\d+xx-sr(\d+)$') +Assert-Eq -Label "Non-standard branch name does NOT match" -Expected $false -Actual $badMatch.Success + +# ─────────── SR-source validation rules (no network) ─────────── + +Write-Host "`n[Unit] SR-source branch validation rules" -ForegroundColor Cyan + +# These patterns mirror $Script:ForbiddenSrPatterns in the script. If the +# script's rule list changes, this test list must be updated to match. +$forbidden = @('^inflight/', '^staging/', '^backport/') + +foreach ($case in @( + @{ Branch = 'inflight/current'; ShouldMatch = $true ; Label = 'inflight/current is forbidden' } + @{ Branch = 'inflight/candidate'; ShouldMatch = $true ; Label = 'inflight/candidate is forbidden' } + @{ Branch = 'staging/foo'; ShouldMatch = $true ; Label = 'staging/* is forbidden' } + @{ Branch = 'backport/pr-31149'; ShouldMatch = $true ; Label = 'backport/* is forbidden' } + @{ Branch = 'release/10.0.1xx-sr7'; ShouldMatch = $false ; Label = 'release/*-sr* is allowed' } + @{ Branch = 'main'; ShouldMatch = $false ; Label = 'main is allowed' } +)) { + $hit = $false + foreach ($p in $forbidden) { + if ($case.Branch -match $p) { $hit = $true; break } + } + Assert-Eq -Label $case.Label -Expected $case.ShouldMatch -Actual $hit +} + +# ─────────── E2E smoke test against SR7 ─────────── + +if (-not $SkipE2E) { + Write-Host "`n[E2E] Smoke test against SR7 known-answer set" -ForegroundColor Cyan + + $scriptPath = Join-Path $PSScriptRoot '..' 'scripts' 'Get-ReleaseReadiness.ps1' + $outDir = Join-Path ([System.IO.Path]::GetTempPath()) "release-readiness-test-$(Get-Date -Format 'yyyyMMddHHmmss')" + + # Test the SR commits + source PR phase only (fast: ~10s) + Write-Host " Running: -Phase commits..." -ForegroundColor Gray + try { + & pwsh -NoProfile -File $scriptPath ` + -SrBranch 'release/10.0.1xx-sr7' ` + -Phase commits ` + -OutputDir $outDir ` + -NoFetch 2>&1 | Out-Null + } catch { + Write-Host " ❌ E2E script invocation failed: $_" -ForegroundColor Red + $script:failed++ + # Hard-fail immediately: a bare `return` here exits at script scope and + # bypasses the terminal `exit $(... $script:failed ...)`, so a crashed + # script-under-test could still exit 0 (CI green). exit 1 is unambiguous. + exit 1 + } + + $srcPrsFile = Join-Path $outDir 'sr-source-prs.txt' + if (-not (Test-Path $srcPrsFile)) { + Write-Host " ❌ sr-source-prs.txt was not created" -ForegroundColor Red + $script:failed++ + } else { + $srcPrs = Get-Content $srcPrsFile + # Expected: backport PR 35428 (Android #35313 fix backport) MUST be in the list + $has35428 = $srcPrs -contains '35428' + Assert-Eq -Label "SR7 source-PRs contains #35428 (Android #35313 backport)" ` + -Expected $true -Actual $has35428 + + # Expected: #35609 (iOS/Mac #35326 fix) was NOT backported — must NOT appear + $has35609 = $srcPrs -contains '35609' + Assert-Eq -Label "SR7 source-PRs does NOT contain #35609 (#35326 fix, not backported)" ` + -Expected $false -Actual $has35609 + + # Expected: count is in the right ballpark (we measured 54 manually) + Write-Host " Source PR count: $($srcPrs.Count) (expected ~50-60)" -ForegroundColor Gray + Assert-Eq -Label "SR7 source-PR count in expected range" ` + -Expected $true -Actual ($srcPrs.Count -ge 40 -and $srcPrs.Count -le 100) + } + + # Cleanup + if (Test-Path $outDir) { Remove-Item -Recurse -Force $outDir } + + # ─────────── -InheritFromPriorSr E2E: SR8-candidate-style ─────────── + Write-Host "`n[E2E] Candidate mode with -InheritFromPriorSr (SR8-style)" -ForegroundColor Cyan + + # Negative: -InheritFromPriorSr without -Candidate must throw + $bogusOut = Join-Path ([System.IO.Path]::GetTempPath()) "rr-test-bogus-$(Get-Date -Format 'HHmmss')" + $stderr = & pwsh -NoProfile -File $scriptPath ` + -SrBranch 'release/10.0.1xx-sr7' ` + -InheritFromPriorSr ` + -Phase commits ` + -OutputDir $bogusOut ` + -NoFetch 2>&1 + $threw = ($LASTEXITCODE -ne 0) -or ($stderr -match 'only valid with -Candidate') + Assert-Eq -Label "-InheritFromPriorSr without -Candidate is rejected" ` + -Expected $true -Actual $threw + if (Test-Path $bogusOut) { Remove-Item -Recurse -Force $bogusOut } + + # Positive: candidate mode + inheritance must produce a non-empty union + $candOut = Join-Path ([System.IO.Path]::GetTempPath()) "rr-test-cand-$(Get-Date -Format 'HHmmss')" + & pwsh -NoProfile -File $scriptPath ` + -SrBranch 'release/10.0.1xx-sr7' ` + -Candidate -InheritFromPriorSr ` + -Phase commits ` + -OutputDir $candOut ` + -NoFetch 2>&1 | Out-Null + $candJson = Join-Path $candOut 'release-readiness.json' + if (-not (Test-Path $candJson)) { + Write-Host " ❌ candidate JSON not created" -ForegroundColor Red + $script:failed++ + } else { + $cand = Get-Content $candJson -Raw | ConvertFrom-Json + $sc = $cand.srContents + # Inherited count must be > 0 (SR7 has commits not on main) + Assert-Eq -Label "Inherited commit count > 0 when -InheritFromPriorSr is set" ` + -Expected $true -Actual ($sc.inheritedCommitCount -gt 0) + # Total source PRs must be >= primary alone + Assert-Eq -Label "Total sourcePrs >= primarySourcePrs (union grows)" ` + -Expected $true -Actual ($sc.sourcePrs.Count -ge $sc.primarySourcePrs.Count) + # Metadata flag is persisted + Assert-Eq -Label "metadata.inheritFromPriorSr is true" ` + -Expected $true -Actual $cand.metadata.inheritFromPriorSr + # The well-known SR7 backport (#35428) must appear in the union (it's in SR7-only) + $hasInherited = $sc.sourcePrs -contains 35428 + Assert-Eq -Label "Union sourcePrs contains SR7-only backport #35428" ` + -Expected $true -Actual $hasInherited + } + if (Test-Path $candOut) { Remove-Item -Recurse -Force $candOut } +} + +# ─────────── Tracker detection algorithm (Find-ReleaseReadinessTrackers.ps1) ─────────── + +Write-Host "`n[Unit] Tracker detection regex contracts" -ForegroundColor Cyan + +$detectScriptPath = Join-Path $PSScriptRoot '..' 'scripts' 'Find-ReleaseReadinessTrackers.ps1' +if (-not (Test-Path $detectScriptPath)) { + Write-Host " ❌ Find-ReleaseReadinessTrackers.ps1 missing at $detectScriptPath" -ForegroundColor Red + $script:failed++ +} else { + # Dot-source to expose the strict regex constants (guarded against main execution) + . $detectScriptPath + + $branchRegex = $Global:FindReleaseReadinessTrackers_StrictSrBranchRegex + $tagRegex = $Global:FindReleaseReadinessTrackers_StrictStableTagRegex + + # Branch acceptance — these MUST match + foreach ($case in @( + @{ Name = 'release/10.0.1xx-sr1'; Major = 10; Sr = 1 } + @{ Name = 'release/10.0.1xx-sr7'; Major = 10; Sr = 7 } + @{ Name = 'release/10.0.1xx-sr10'; Major = 10; Sr = 10 } + @{ Name = 'release/9.0.1xx-sr9'; Major = 9; Sr = 9 } + @{ Name = 'release/11.0.2xx-sr1'; Major = 11; Sr = 1 } + )) { + $m = [regex]::Match($case.Name, $branchRegex) + Assert-Eq -Label "strict regex accepts $($case.Name)" -Expected $true -Actual $m.Success + if ($m.Success) { + Assert-Eq -Label " -> extracts major=$($case.Major)" -Expected $case.Major -Actual ([int]$m.Groups[1].Value) + Assert-Eq -Label " -> extracts sr=$($case.Sr)" -Expected $case.Sr -Actual ([int]$m.Groups[2].Value) + } + } + + # Branch rejection — these MUST NOT match (false positives the reviewers flagged) + foreach ($name in @( + 'release/10.0.1xx-sr8-backup' # backup suffix + 'release/10.0.1xx-sr10-test' # test suffix + 'release/10.0.1xx-sr-next' # non-numeric + 'release/10.0.1xx-sr8-old' # old suffix + 'release/10.0.1xx-sr8-hotfix' # hotfix suffix + 'release/10.0.1xx-srN' # placeholder + 'release/10.0.1xx-sr8 ' # trailing whitespace + 'release/10.0.1xx-SR8' # case-mismatch (regex is case-sensitive) + 'release/10.0.1xx' # GA, not SR + 'release/10.0.1xx-preview7' # preview + 'release/10.0.1xx-rc1' # rc + 'inflight/current' # integration ref + 'main' # not a release branch + 'feature/sr8' # not a release branch + )) { + $m = [regex]::Match($name, $branchRegex) + Assert-Eq -Label "strict regex rejects $name" -Expected $false -Actual $m.Success + } + + # sr08 (leading zero) is debatable - .NET tooling normalizes to sr8. The current + # strict regex DOES accept "sr08" because \d+ doesn't forbid leading zeros. We + # consider this acceptable: lane 1 will fetch the branch, classify it normally, + # and the canonical key would be "net10-sr8" once parsed as [int]. If you need + # to forbid the leading zero, tighten to `-sr([1-9]\d*)`. + $sr08 = [regex]::Match('release/10.0.1xx-sr08', $branchRegex) + Assert-Eq -Label "regex tolerates 'sr08' leading zero (parsed as int 8)" ` + -Expected 8 -Actual $(if ($sr08.Success) { [int]$sr08.Groups[2].Value } else { -1 }) + + # Stable tag acceptance — these MUST match + foreach ($case in @( + @{ Name = '10.0.0'; Major = 10; Patch = 0 } + @{ Name = '10.0.70'; Major = 10; Patch = 70 } + @{ Name = '10.0.71'; Major = 10; Patch = 71 } + @{ Name = '10.0.100'; Major = 10; Patch = 100 } + )) { + $m = [regex]::Match($case.Name, $tagRegex) + Assert-Eq -Label "stable-tag regex accepts $($case.Name)" -Expected $true -Actual $m.Success + } + + # Stable tag rejection — prerelease tags MUST be ignored when computing highest shipped + foreach ($name in @( + '11.0.0-preview.1.26107' + '11.0.0-rc.1.25424.2' + '10.0.71-rtm.123' + '10.0.71-servicing' + '10.0' # missing patch + '10.0.71.0' # extra segment + )) { + $m = [regex]::Match($name, $tagRegex) + Assert-Eq -Label "stable-tag regex rejects $name (prerelease/malformed)" -Expected $false -Actual $m.Success + } + + # Regression label inference — exercise the helper + Write-Host "`n[Unit] Tracker regression-label inference" -ForegroundColor Cyan + foreach ($case in @( + @{ Major = 10; Sr = 7; Expected = @('regressed-in-10.0.60', 'regressed-in-10.0.70') } + @{ Major = 10; Sr = 8; Expected = @('regressed-in-10.0.70', 'regressed-in-10.0.80') } + @{ Major = 10; Sr = 9; Expected = @('regressed-in-10.0.80', 'regressed-in-10.0.90') } + @{ Major = 10; Sr = 10; Expected = @('regressed-in-10.0.90', 'regressed-in-10.0.100') } + @{ Major = 10; Sr = 1; Expected = @('regressed-in-10.0.0', 'regressed-in-10.0.10') } + @{ Major = 11; Sr = 1; Expected = @('regressed-in-11.0.0', 'regressed-in-11.0.10') } + )) { + $actual = (New-RegressionLabelList -Major $case.Major -SrNumber $case.Sr) -join ',' + $expected = $case.Expected -join ',' + Assert-Eq -Label "regression labels for major=$($case.Major) sr=$($case.Sr)" ` + -Expected $expected -Actual $actual + } + + # ─────────── In-flight tag-existence check ─────────── + # The authoritative ship signal is the existence of the stable tag + # `.0.` (created when release notes publish). These tests + # exercise the two helpers backing that rule. + + Write-Host "`n[Unit] Get-ShippedPatchSet" -ForegroundColor Cyan + + # Builds a HashSet[int] from a tag list, dropping prereleases and noise. + $live10Tags = @( + '10.0.0', '10.0.1', '10.0.10', '10.0.11', '10.0.20', + '10.0.30', '10.0.31', '10.0.40', '10.0.41', + '10.0.50', '10.0.51', '10.0.60', '10.0.70' + ) + $set = Get-ShippedPatchSet -StableTags $live10Tags + Assert-Eq -Label "set is HashSet[int]" ` + -Expected $true ` + -Actual ($set -is [System.Collections.Generic.HashSet[int]]) + Assert-Eq -Label "set count = 13 distinct shipped patches" -Expected 13 -Actual $set.Count + Assert-Eq -Label "set contains shipped patch 70" -Expected $true -Actual $set.Contains(70) + Assert-Eq -Label "set contains GA patch 0" -Expected $true -Actual $set.Contains(0) + Assert-Eq -Label "set does NOT contain 71" -Expected $false -Actual $set.Contains(71) + Assert-Eq -Label "set does NOT contain 80" -Expected $false -Actual $set.Contains(80) + Assert-Eq -Label "set does NOT contain 90" -Expected $false -Actual $set.Contains(90) + + # Prereleases must NOT count as shipped. + $mixed = @('10.0.70', '10.0.71-rtm.123', '10.0.71-servicing', '11.0.0-preview.1.26107') + $mixedSet = Get-ShippedPatchSet -StableTags $mixed + Assert-Eq -Label "prerelease tags ignored: only stable 10.0.70 counts" -Expected 1 -Actual $mixedSet.Count + Assert-Eq -Label "prerelease '10.0.71-rtm.123' does NOT mark 71 shipped" -Expected $false -Actual $mixedSet.Contains(71) + + # Edge cases. + $emptySet = Get-ShippedPatchSet -StableTags @() + Assert-Eq -Label "empty input -> empty set" -Expected 0 -Actual $emptySet.Count + $nullSet = Get-ShippedPatchSet -StableTags $null + Assert-Eq -Label "null input -> empty set" -Expected 0 -Actual $nullSet.Count + + # Duplicate tags collapse (HashSet semantics). + $dupSet = Get-ShippedPatchSet -StableTags @('10.0.70', '10.0.70', '10.0.71') + Assert-Eq -Label "duplicate tags collapse" -Expected 2 -Actual $dupSet.Count + + Write-Host "`n[Unit] Test-IsBranchInFlight" -ForegroundColor Cyan + + # The current live state: SR7 (patch 71) and SR8 (patch 80) are in-flight, + # SR6 (patch 60) is already shipped. + Assert-Eq -Label "SR6 patch 60 — tag 10.0.60 exists -> shipped" -Expected $false -Actual (Test-IsBranchInFlight -BranchPatch 60 -ShippedPatches $set) + Assert-Eq -Label "SR7 patch 71 — tag 10.0.71 missing -> in-flight" -Expected $true -Actual (Test-IsBranchInFlight -BranchPatch 71 -ShippedPatches $set) + Assert-Eq -Label "SR8 patch 80 — tag 10.0.80 missing -> in-flight" -Expected $true -Actual (Test-IsBranchInFlight -BranchPatch 80 -ShippedPatches $set) + + # A second-patch ship in an SR family (10.0.31 → SR3 already shipped twice). + Assert-Eq -Label "patch 31 — tag 10.0.31 exists -> shipped" -Expected $false -Actual (Test-IsBranchInFlight -BranchPatch 31 -ShippedPatches $set) + Assert-Eq -Label "patch 32 — tag 10.0.32 missing -> in-flight (hypothetical SR3 hotfix branch)" ` + -Expected $true -Actual (Test-IsBranchInFlight -BranchPatch 32 -ShippedPatches $set) + + # Out-of-order ship scenario: tag for SR8 (80) exists but not for SR7 (71). + # New tag-based rule must still mark SR7 in-flight; the old highest-shipped + # comparison would have wrongly classified it as shipped. + $outOfOrder = Get-ShippedPatchSet -StableTags @('10.0.0', '10.0.60', '10.0.80') + Assert-Eq -Label "out-of-order: SR7 patch 71 still in-flight even though 80 shipped" ` + -Expected $true -Actual (Test-IsBranchInFlight -BranchPatch 71 -ShippedPatches $outOfOrder) + Assert-Eq -Label "out-of-order: SR8 patch 80 correctly shipped" -Expected $false -Actual (Test-IsBranchInFlight -BranchPatch 80 -ShippedPatches $outOfOrder) + + # Hotfix branch resetting PatchVersion below highest known patch. + # Example: SR2 branch bumped back to patch 22 to prepare a security + # release after SR7 already shipped. Tag 10.0.22 doesn't exist → in-flight. + $hotfix = Get-ShippedPatchSet -StableTags @('10.0.0', '10.0.20', '10.0.70') + Assert-Eq -Label "hotfix: SR2 patch 22 still in-flight when latest shipped is 70" ` + -Expected $true -Actual (Test-IsBranchInFlight -BranchPatch 22 -ShippedPatches $hotfix) + Assert-Eq -Label "hotfix: SR2 patch 20 is the already-shipped baseline" -Expected $false -Actual (Test-IsBranchInFlight -BranchPatch 20 -ShippedPatches $hotfix) + + # Empty ship set: every branch must be in-flight. + Assert-Eq -Label "no shipped tags yet: patch 0 (GA) in-flight" -Expected $true -Actual (Test-IsBranchInFlight -BranchPatch 0 -ShippedPatches $emptySet) + Assert-Eq -Label "no shipped tags yet: patch 11 in-flight" -Expected $true -Actual (Test-IsBranchInFlight -BranchPatch 11 -ShippedPatches $emptySet) + + # ─────────── Preview-tag regex contract ─────────── + Write-Host "`n[Unit] Preview tag regex (.0.0-preview..[.])" -ForegroundColor Cyan + $previewTagCases = @( + @{ Tag = '11.0.0-preview.5.26304.4'; Match = $true; Major = 11; PreviewN = 5 } # GA preview with build + @{ Tag = '11.0.0-preview.1.26107'; Match = $true; Major = 11; PreviewN = 1 } # GA preview without build suffix + @{ Tag = '10.0.0-preview.7.25406.3'; Match = $true; Major = 10; PreviewN = 7 } # net10 preview7 + @{ Tag = '11.0.0-preview.10.26999'; Match = $true; Major = 11; PreviewN = 10 } # double-digit preview + @{ Tag = '10.0.70'; Match = $false } # stable tag should NOT match + @{ Tag = '11.0.0-rc.1.26404.4'; Match = $false } # rc, not preview + @{ Tag = '11.0.0-preview.5'; Match = $false } # missing date + @{ Tag = '11.0.0-preview.5.26304x'; Match = $false } # garbage suffix + ) + foreach ($case in $previewTagCases) { + $m = [regex]::Match($case.Tag, $Script:StrictPreviewTagRegex) + Assert-Eq -Label "tag '$($case.Tag)' match=$($case.Match)" -Expected $case.Match -Actual $m.Success + if ($case.Match) { + Assert-Eq -Label " -> major=$($case.Major)" -Expected $case.Major -Actual ([int]$m.Groups[1].Value) + Assert-Eq -Label " -> previewN=$($case.PreviewN)" -Expected $case.PreviewN -Actual ([int]$m.Groups[2].Value) + } + } + + # ─────────── Preview-branch regex contract ─────────── + Write-Host "`n[Unit] Preview branch regex (release/.0.xx-preview)" -ForegroundColor Cyan + $previewBranchCases = @( + @{ Branch = 'release/11.0.1xx-preview6'; Match = $true; Major = 11; PreviewN = 6 } + @{ Branch = 'release/10.0.1xx-preview7'; Match = $true; Major = 10; PreviewN = 7 } + @{ Branch = 'release/11.0.1xx-preview10'; Match = $true; Major = 11; PreviewN = 10 } + @{ Branch = 'release/10.0.1xx-sr7'; Match = $false } # SR branch must NOT match preview + @{ Branch = 'release/11.0.1xx-preview6.1'; Match = $false } # no dotted suffix + @{ Branch = 'release/11.0.1xx-previewa'; Match = $false } # preview number must be digits + @{ Branch = 'release/11.0.1xx-preview6/x'; Match = $false } # no trailing path + @{ Branch = 'release/11.0.0-preview6'; Match = $false } # missing patch band + ) + foreach ($case in $previewBranchCases) { + $m = [regex]::Match($case.Branch, $Script:StrictPreviewBranchRegex) + Assert-Eq -Label "branch '$($case.Branch)' match=$($case.Match)" -Expected $case.Match -Actual $m.Success + if ($case.Match) { + Assert-Eq -Label " -> major=$($case.Major)" -Expected $case.Major -Actual ([int]$m.Groups[1].Value) + Assert-Eq -Label " -> previewN=$($case.PreviewN)" -Expected $case.PreviewN -Actual ([int]$m.Groups[2].Value) + } + } + + # ─────────── Preview regression label inference ─────────── + Write-Host "`n[Unit] Preview regression-label inference" -ForegroundColor Cyan + foreach ($case in @( + @{ Major = 11; Preview = 1; Expected = @('regressed-in-11.0.0-preview1') } # preview1 has only its own label + @{ Major = 11; Preview = 6; Expected = @('regressed-in-11.0.0-preview5', 'regressed-in-11.0.0-preview6') } + @{ Major = 12; Preview = 3; Expected = @('regressed-in-12.0.0-preview2', 'regressed-in-12.0.0-preview3') } + )) { + $actual = (New-PreviewRegressionLabelList -Major $case.Major -PreviewNumber $case.Preview) -join ',' + $expected = $case.Expected -join ',' + Assert-Eq -Label "preview labels for major=$($case.Major) preview=$($case.Preview)" ` + -Expected $expected -Actual $actual + } + + # ─────────── Get-ShippedPreviewSet ─────────── + Write-Host "`n[Unit] Get-ShippedPreviewSet" -ForegroundColor Cyan + $live11Previews = @( + '11.0.0-preview.1.26107', + '11.0.0-preview.2.26152.10', + '11.0.0-preview.3.26203.7', + '11.0.0-preview.4.26230.3', + '11.0.0-preview.5.26304.4' + ) + $previewSet = Get-ShippedPreviewSet -PreviewTags $live11Previews + Assert-Eq -Label "preview set is HashSet[int]" ` + -Expected $true ` + -Actual ($previewSet -is [System.Collections.Generic.HashSet[int]]) + Assert-Eq -Label "preview set count = 5" -Expected 5 -Actual $previewSet.Count + Assert-Eq -Label "preview set contains 5" -Expected $true -Actual $previewSet.Contains(5) + Assert-Eq -Label "preview set does NOT contain 6" -Expected $false -Actual $previewSet.Contains(6) + + # Multiple tags for the same preview N collapse (preview3 had 3 ship-day candidates in practice). + $multiTag = @('11.0.0-preview.5.26301.1', '11.0.0-preview.5.26304.4', '11.0.0-preview.6.26350.0') + $multiSet = Get-ShippedPreviewSet -PreviewTags $multiTag + Assert-Eq -Label "multiple tags for same preview N collapse" -Expected 2 -Actual $multiSet.Count + Assert-Eq -Label "multi: contains 5" -Expected $true -Actual $multiSet.Contains(5) + Assert-Eq -Label "multi: contains 6" -Expected $true -Actual $multiSet.Contains(6) + + # Stable tags must not pollute preview set. + $stableMix = @('10.0.70', '11.0.0', '11.0.0-preview.5.26304.4') + $mixSet = Get-ShippedPreviewSet -PreviewTags $stableMix + Assert-Eq -Label "stable tags ignored by preview set" -Expected 1 -Actual $mixSet.Count + Assert-Eq -Label "preview set only contains 5" -Expected $true -Actual $mixSet.Contains(5) + + # Empty/null inputs. + $emptyPreviewSet = Get-ShippedPreviewSet -PreviewTags @() + Assert-Eq -Label "empty preview input -> empty set" -Expected 0 -Actual $emptyPreviewSet.Count + $nullPreviewSet = Get-ShippedPreviewSet -PreviewTags $null + Assert-Eq -Label "null preview input -> empty set" -Expected 0 -Actual $nullPreviewSet.Count + + # ─────────── Test-IsPreviewBranchInFlight ─────────── + Write-Host "`n[Unit] Test-IsPreviewBranchInFlight" -ForegroundColor Cyan + # Live state: net11 has previews 1–5 shipped; preview6 is the in-flight candidate. + Assert-Eq -Label "preview1 (tag exists) -> NOT in-flight" -Expected $false -Actual (Test-IsPreviewBranchInFlight -PreviewNumber 1 -ShippedPreviews $previewSet) + Assert-Eq -Label "preview5 (tag exists) -> NOT in-flight" -Expected $false -Actual (Test-IsPreviewBranchInFlight -PreviewNumber 5 -ShippedPreviews $previewSet) + Assert-Eq -Label "preview6 (no tag) -> in-flight" -Expected $true -Actual (Test-IsPreviewBranchInFlight -PreviewNumber 6 -ShippedPreviews $previewSet) + Assert-Eq -Label "preview7 (no tag) -> in-flight" -Expected $true -Actual (Test-IsPreviewBranchInFlight -PreviewNumber 7 -ShippedPreviews $previewSet) + + # Empty shipped set: every preview is in-flight. + Assert-Eq -Label "no shipped previews: preview1 in-flight" -Expected $true -Actual (Test-IsPreviewBranchInFlight -PreviewNumber 1 -ShippedPreviews $emptyPreviewSet) + Assert-Eq -Label "no shipped previews: preview20 in-flight" -Expected $true -Actual (Test-IsPreviewBranchInFlight -PreviewNumber 20 -ShippedPreviews $emptyPreviewSet) +} + +# ─────────── E2E: Run detection against this repo and validate trackers ─────────── + +if (-not $SkipE2E) { + Write-Host "`n[E2E] Detection against live repo" -ForegroundColor Cyan + Write-Host " Under the tag-existence rule we expect FOUR trackers:" -ForegroundColor DarkGray + Write-Host " - SR2 (patch=21, no tag 10.0.21) - in-flight but inactive (workflow will skip — no recent commits)" -ForegroundColor DarkGray + Write-Host " - SR3 (patch=33, no tag 10.0.33) - in-flight but inactive (workflow will skip — no recent commits)" -ForegroundColor DarkGray + Write-Host " - SR8 (patch=80, no tag 10.0.80) - in-flight, active" -ForegroundColor DarkGray + Write-Host " - SR9 (candidate off main) - active" -ForegroundColor DarkGray + Write-Host " NOTE: SR7 shipped 2026-06-05 (tag 10.0.71); no longer produces a tracker." -ForegroundColor DarkGray + + $detectOut = Join-Path ([System.IO.Path]::GetTempPath()) "rr-detect-$(Get-Date -Format 'HHmmss').json" + try { + & pwsh -NoProfile -File $detectScriptPath -NoFetch -OutputJson $detectOut 2>&1 | Out-Null + if (-not (Test-Path $detectOut)) { + Write-Host " ❌ detection JSON not created" -ForegroundColor Red + $script:failed++ + } else { + $detected = Get-Content $detectOut -Raw | ConvertFrom-Json + + Assert-Eq -Label "majorVersion is 10" -Expected 10 -Actual $detected.majorVersion + Assert-Eq -Label "mainBranch is 'main'" -Expected 'main' -Actual $detected.mainBranch + Assert-Eq -Label "highestShippedTag is '10.0.71'" -Expected '10.0.71' -Actual $detected.highestShippedTag + Assert-Eq -Label "highestShippedPreviewTag carries net10's last preview" ` + -Expected '10.0.0-preview.7.25406.3' -Actual $detected.highestShippedPreviewTag + Assert-Eq -Label "tracker count is 4 (SR2+SR3+SR8+SR9 — SR7 shipped)" ` + -Expected 4 -Actual $detected.trackers.Count + # All trackers in single-major net10 mode must be SR-flavored. (Net10's + # previews 1–7 all shipped + no in-flight preview branch -> no preview tracker.) + foreach ($t in $detected.trackers) { + Assert-Eq -Label "tracker '$($t.canonicalKey)' has branchType='sr'" ` + -Expected 'sr' -Actual $t.branchType + } + + $bySr = @{} + foreach ($t in $detected.trackers) { $bySr[[int]$t.srNumber] = $t } + + # SR2 (in-flight, INACTIVE — workflow's activity gate prevents new issue) + if ($bySr.ContainsKey(2)) { + $sr2 = $bySr[2] + Assert-Eq -Label "SR2 mode = in-flight (tag 10.0.21 absent)" ` + -Expected 'in-flight' -Actual $sr2.mode + Assert-Eq -Label "SR2 expectedTag = 10.0.21" -Expected '10.0.21' -Actual $sr2.expectedTag + Assert-Eq -Label "SR2 hasRecentActivity = false (workflow will skip new issue)" ` + -Expected $false -Actual $sr2.hasRecentActivity + } else { + Write-Host " ❌ SR2 tracker missing (tag rule should pick it up — patch=21, no tag)" -ForegroundColor Red; $script:failed++ + } + + # SR3 (in-flight, INACTIVE) + if ($bySr.ContainsKey(3)) { + $sr3 = $bySr[3] + Assert-Eq -Label "SR3 mode = in-flight (tag 10.0.33 absent)" ` + -Expected 'in-flight' -Actual $sr3.mode + Assert-Eq -Label "SR3 expectedTag = 10.0.33" -Expected '10.0.33' -Actual $sr3.expectedTag + Assert-Eq -Label "SR3 hasRecentActivity = false" -Expected $false -Actual $sr3.hasRecentActivity + } else { + Write-Host " ❌ SR3 tracker missing (tag rule should pick it up — patch=33, no tag)" -ForegroundColor Red; $script:failed++ + } + + # SR7 (shipped 2026-06-05 as 10.0.71 — Lane 1 should NOT emit a tracker) + if ($bySr.ContainsKey(7)) { + Write-Host " ❌ SR7 tracker should NOT be present (tag 10.0.71 shipped 2026-06-05)" -ForegroundColor Red; $script:failed++ + } else { + Assert-Eq -Label "SR7 tracker absent (shipped)" -Expected $true -Actual $true + } + + # SR8 (in-flight, ACTIVE) + if ($bySr.ContainsKey(8)) { + $sr8 = $bySr[8] + Assert-Eq -Label "SR8 mode = in-flight" -Expected 'in-flight' -Actual $sr8.mode + Assert-Eq -Label "SR8 canonicalKey" -Expected 'net10-sr8' -Actual $sr8.canonicalKey + Assert-Eq -Label "SR8 branchName" -Expected 'release/10.0.1xx-sr8' -Actual $sr8.branchName + Assert-Eq -Label "SR8 branchExists = true" -Expected $true -Actual $sr8.branchExists + Assert-Eq -Label "SR8 expectedTag = 10.0.80" -Expected '10.0.80' -Actual $sr8.expectedTag + Assert-Eq -Label "SR8 hasRecentActivity = true" -Expected $true -Actual $sr8.hasRecentActivity + Assert-Eq -Label "SR8 regression labels" ` + -Expected 'regressed-in-10.0.70,regressed-in-10.0.80' ` + -Actual ($sr8.regressionLabels -join ',') + } else { + Write-Host " ❌ SR8 tracker missing" -ForegroundColor Red; $script:failed++ + } + + # SR9 (candidate from main, ACTIVE) + if ($bySr.ContainsKey(9)) { + $sr9 = $bySr[9] + Assert-Eq -Label "SR9 mode = candidate" -Expected 'candidate' -Actual $sr9.mode + Assert-Eq -Label "SR9 canonicalKey" -Expected 'net10-sr9' -Actual $sr9.canonicalKey + Assert-Eq -Label "SR9 branchName = canonical proposed slug" ` + -Expected 'release/10.0.1xx-sr9' -Actual $sr9.branchName + Assert-Eq -Label "SR9 branchExists = false (not cut yet)" ` + -Expected $false -Actual $sr9.branchExists + Assert-Eq -Label "SR9 surveyRef = main" -Expected 'main' -Actual $sr9.surveyRef + Assert-Eq -Label "SR9 priorSrBranch = SR8 branch" ` + -Expected 'release/10.0.1xx-sr8' -Actual $sr9.priorSrBranch + Assert-Eq -Label "SR9 expectedPatch = 90" -Expected 90 -Actual $sr9.expectedPatch + Assert-Eq -Label "SR9 hasRecentActivity = true" -Expected $true -Actual $sr9.hasRecentActivity + Assert-Eq -Label "SR9 regression labels" ` + -Expected 'regressed-in-10.0.80,regressed-in-10.0.90' ` + -Actual ($sr9.regressionLabels -join ',') + } else { + Write-Host " ❌ SR9 tracker missing" -ForegroundColor Red; $script:failed++ + } + + # Active SRs (the ones the workflow will actually post) all have activity. + # SR7 shipped 2026-06-05 (no longer in the tracker set); only SR8 + SR9 are active. + foreach ($srNum in @(8, 9)) { + if ($bySr.ContainsKey($srNum)) { + Assert-Eq -Label "SR$srNum hasRecentActivity == true (active SR)" ` + -Expected $true -Actual $bySr[$srNum].hasRecentActivity + } + } + } + } finally { + if (Test-Path $detectOut) { Remove-Item -Force $detectOut } + } + + # ──────────── E2E: -AllActiveMajors multi-major envelope ──────────── + # In the unified post-consolidation shape, one invocation must surface every + # active major (main's + any net.0 ≥ main). Expected current state: + # - net10 -> 4 SR trackers (SR2, SR3, SR8, SR9), no preview tracker + # (SR7 shipped 2026-06-05; every net10 preview branch already shipped + net10.0 isn't in preview cycle) + # - net11 -> 0 SR trackers (pre-GA: no `11.0.0` tag), 1 preview tracker + # (preview6 candidate from net11.0) + Write-Host "`n[E2E] Detection with -AllActiveMajors" -ForegroundColor Cyan + Write-Host " Expected:" -ForegroundColor DarkGray + Write-Host " - majors[].length = 2 (net10 + net11)" -ForegroundColor DarkGray + Write-Host " - net10 trackers: 4 SR (sr2/sr3/sr8/sr9), 0 preview (SR7 shipped 2026-06-05)" -ForegroundColor DarkGray + Write-Host " - net11 trackers: 0 SR (pre-GA), 1 preview (preview6 candidate from net11.0)" -ForegroundColor DarkGray + + $multiOut = Join-Path ([System.IO.Path]::GetTempPath()) "rr-detect-allmajors-$(Get-Date -Format 'HHmmss').json" + try { + & pwsh -NoProfile -File $detectScriptPath -NoFetch -AllActiveMajors -OutputJson $multiOut 2>&1 | Out-Null + if (-not (Test-Path $multiOut)) { + Write-Host " ❌ allmajors detection JSON not created" -ForegroundColor Red; $script:failed++ + } else { + $multi = Get-Content $multiOut -Raw | ConvertFrom-Json + Assert-Eq -Label "AllActiveMajors envelope has no top-level trackers" ` + -Expected $false -Actual ($multi.PSObject.Properties.Name -contains 'trackers') + Assert-Eq -Label "AllActiveMajors envelope has top-level majors[]" ` + -Expected $true -Actual ($multi.PSObject.Properties.Name -contains 'majors') + Assert-Eq -Label "majors[] contains exactly 2 entries (net10 + net11)" ` + -Expected 2 -Actual $multi.majors.Count + + $byMajor = @{} + foreach ($m in $multi.majors) { $byMajor[[int]$m.majorVersion] = $m } + + # net10 — same as single-major run, all SR trackers. + if ($byMajor.ContainsKey(10)) { + $net10 = $byMajor[10] + Assert-Eq -Label "net10 mainBranch is 'main'" -Expected 'main' -Actual $net10.mainBranch + Assert-Eq -Label "net10 highestShippedTag is '10.0.71'" -Expected '10.0.71' -Actual $net10.highestShippedTag + Assert-Eq -Label "net10 tracker count is 4 (no preview lane, SR7 shipped)" -Expected 4 -Actual $net10.trackers.Count + $srCount = @($net10.trackers | Where-Object branchType -eq 'sr').Count + $previewCount = @($net10.trackers | Where-Object branchType -eq 'preview').Count + Assert-Eq -Label "net10 has 4 SR trackers" -Expected 4 -Actual $srCount + Assert-Eq -Label "net10 has 0 preview trackers" -Expected 0 -Actual $previewCount + } else { + Write-Host " ❌ majors[] missing net10 entry" -ForegroundColor Red; $script:failed++ + } + + # net11 — pre-GA: no SR trackers; expects preview6 candidate from net11.0. + if ($byMajor.ContainsKey(11)) { + $net11 = $byMajor[11] + Assert-Eq -Label "net11 mainBranch is 'net11.0'" -Expected 'net11.0' -Actual $net11.mainBranch + Assert-Eq -Label "net11 highestShippedTag is null (pre-GA)" -Expected $true -Actual ([string]::IsNullOrEmpty($net11.highestShippedTag)) + Assert-Eq -Label "net11 highestShippedPreviewTag carries preview5 tag" ` + -Expected '11.0.0-preview.5.26304.4' -Actual $net11.highestShippedPreviewTag + Assert-Eq -Label "net11 tracker count is 1 (preview6 only)" -Expected 1 -Actual $net11.trackers.Count + $previewTrackers = @($net11.trackers | Where-Object branchType -eq 'preview') + Assert-Eq -Label "net11 has 1 preview tracker" -Expected 1 -Actual $previewTrackers.Count + $srTrackers = @($net11.trackers | Where-Object branchType -eq 'sr') + Assert-Eq -Label "net11 has 0 SR trackers (pre-GA -> Lane 2 skipped)" -Expected 0 -Actual $srTrackers.Count + + $preview6 = $previewTrackers[0] + Assert-Eq -Label "preview6 canonicalKey" -Expected 'net11-preview6' -Actual $preview6.canonicalKey + Assert-Eq -Label "preview6 mode = candidate" -Expected 'candidate' -Actual $preview6.mode + Assert-Eq -Label "preview6 surveyRef = net11.0" -Expected 'net11.0' -Actual $preview6.surveyRef + Assert-Eq -Label "preview6 expectedTagPrefix" -Expected '11.0.0-preview.6.' -Actual $preview6.expectedTagPrefix + Assert-Eq -Label "preview6 previewNumber = 6" -Expected 6 -Actual $preview6.previewNumber + Assert-Eq -Label "preview6 milestone name" -Expected '.NET 11.0-preview6' -Actual $preview6.milestoneName + Assert-Eq -Label "preview6 issue title format" ` + -Expected '[Release Readiness] .NET 11.0 preview6 — candidate from net11.0' ` + -Actual $preview6.issueTitle + Assert-Eq -Label "preview6 branchName = canonical proposed slug" ` + -Expected 'release/11.0.1xx-preview6' -Actual $preview6.branchName + Assert-Eq -Label "preview6 branchExists = false (no branch yet)" ` + -Expected $false -Actual $preview6.branchExists + Assert-Eq -Label "preview6 hasRecentActivity = true (active preview cycle)" ` + -Expected $true -Actual $preview6.hasRecentActivity + Assert-Eq -Label "preview6 regressionLabels carries previewN-1 + previewN" ` + -Expected 'regressed-in-11.0.0-preview5,regressed-in-11.0.0-preview6' ` + -Actual ($preview6.regressionLabels -join ',') + } else { + Write-Host " ❌ majors[] missing net11 entry" -ForegroundColor Red; $script:failed++ + } + } + } finally { + if (Test-Path $multiOut) { Remove-Item -Force $multiOut } + } + + # Fail-closed: bad repo path should exit non-zero + Write-Host "`n[E2E] Detection fails closed on invalid repo" -ForegroundColor Cyan + $badRepoOut = Join-Path ([System.IO.Path]::GetTempPath()) "rr-detect-badrepo-$(Get-Date -Format 'HHmmss').json" + $badRepoPath = Join-Path ([System.IO.Path]::GetTempPath()) "rr-detect-non-git-$(Get-Date -Format 'HHmmss')" + try { + New-Item -ItemType Directory -Path $badRepoPath -Force | Out-Null + & pwsh -NoProfile -File $detectScriptPath -NoFetch -Repo $badRepoPath -OutputJson $badRepoOut 2>&1 | Out-Null + $exit = $LASTEXITCODE + $jsonCreated = Test-Path $badRepoOut + Assert-Eq -Label "exits non-zero on non-git path" -Expected $true -Actual ($exit -ne 0) + Assert-Eq -Label "does not write JSON on failure (fail-closed)" -Expected $false -Actual $jsonCreated + } finally { + if (Test-Path $badRepoOut) { Remove-Item -Force $badRepoOut } + if (Test-Path $badRepoPath) { Remove-Item -Recurse -Force $badRepoPath } + } +} + +# ─────────── Unit tests for Get-ReleaseReadiness internals ─────────── +# Dot-source the script in test mode so we can call individual functions +# without invoking the full orchestrator (which requires git + gh + network). +# The TEST_MODE env var short-circuits Invoke-Main at the bottom of the script. +$env:GET_RELEASE_READINESS_TEST_MODE = '1' +try { + $rrScript = Join-Path $PSScriptRoot '..' 'scripts' 'Get-ReleaseReadiness.ps1' + # Dot-source needs to satisfy [Parameter(Mandatory)] for $SrBranch; pass a dummy. + . $rrScript -SrBranch 'release/10.0.1xx-sr1' +} finally { + Remove-Item -Path Env:GET_RELEASE_READINESS_TEST_MODE -ErrorAction SilentlyContinue +} + +# ───── Get-RevertedPrFromSubject (revert false-green guard) ───── +Write-Host "`n[Unit] Get-RevertedPrFromSubject (revert classification)" -ForegroundColor Cyan + +# The reverted-PR must be the ORIGINAL fix, NOT the revert's own trailing (#N). +# GitHub's revert subject is Revert "Title (#1234)" (#5678) — 1234 is the +# reverted fix, 5678 is the revert PR. A greedy pattern previously captured 5678, +# which skipped the SHA-lookup fallback and flipped a reverted regression fix to +# in-sr-active ("ready to ship") instead of in-sr-reverted. +Assert-Eq -Label "Reverted-PR from quoted title returns inner #, not trailing revert #" ` + -Expected 1234 -Actual (Get-RevertedPrFromSubject -Subject 'Revert "Some fix (#1234)" (#5678)') +Assert-Eq -Label "Reverted-PR from branch-prefixed quoted revert" ` + -Expected 35313 -Actual (Get-RevertedPrFromSubject -Subject '[release/10.0.1xx-sr8] Revert "Fix CollectionView (#35313)" (#35804)') +Assert-Eq -Label "Reverted-PR from explicit 'Revert PR #NNNN'" ` + -Expected 35428 -Actual (Get-RevertedPrFromSubject -Subject 'Revert PR #35428 - broke iOS') +Assert-Eq -Label "Revert subject with no inner (#N) yields null (no false reverted-PR)" ` + -Expected $null -Actual (Get-RevertedPrFromSubject -Subject 'Revert "Fix some thing" (#35744)') +Assert-Eq -Label "Non-revert subject yields null" ` + -Expected $null -Actual (Get-RevertedPrFromSubject -Subject '[Android] Fix layout pass (#35900)') +# Internal quotes in the original title must not truncate the match. The old +# [^"]* pattern stopped at the first inner quote and returned null. +Assert-Eq -Label "Reverted-PR from quoted title containing internal quotes" ` + -Expected 1234 -Actual (Get-RevertedPrFromSubject -Subject 'Revert "Fix "weird" bug (#1234)" (#5678)') +# Case-insensitive: a hand-typed lowercase 'revert "..."' subject must resolve. +Assert-Eq -Label "Reverted-PR from lowercase 'revert' subject" ` + -Expected 4321 -Actual (Get-RevertedPrFromSubject -Subject 'revert "fix thing (#4321)" (#8765)') + +# ───── Test-PrIsToolingOnly (false-positive guard #1) ───── +Write-Host "`n[Unit] Test-PrIsToolingOnly (FP guard)" -ForegroundColor Cyan + +# Self-reference case: a workflow/skill PR that mentions a regression issue +$toolingOnlyFiles = @( + @{ path = '.github/workflows/foo.yml'; additions = 10; deletions = 0 } + @{ path = '.github/skills/release-readiness/SKILL.md'; additions = 5; deletions = 0 } + @{ path = 'docs/release-readiness.md'; additions = 3; deletions = 0 } + @{ path = 'eng/scripts/helper.ps1'; additions = 20; deletions = 0 } + @{ path = 'README.md'; additions = 1; deletions = 0 } +) +Assert-Eq -Label "tooling-only PR (workflows + docs + scripts)" -Expected $true ` + -Actual (Test-PrIsToolingOnly -Files $toolingOnlyFiles) + +# Real fix: at least one product file +$realFixFiles = @( + @{ path = 'src/Controls/src/Core/Button.cs'; additions = 20; deletions = 5 } + @{ path = '.github/workflows/foo.yml'; additions = 2; deletions = 0 } # mixed +) +Assert-Eq -Label "real fix PR (src/ + .github/ mixed) is NOT tooling-only" -Expected $false ` + -Actual (Test-PrIsToolingOnly -Files $realFixFiles) + +# Pure src changes +$srcOnlyFiles = @( + @{ path = 'src/Core/src/Layouts/StackLayout.cs'; additions = 50; deletions = 10 } + @{ path = 'src/Core/tests/UnitTests/StackLayoutTests.cs'; additions = 25; deletions = 0 } +) +Assert-Eq -Label "src-only PR is NOT tooling-only" -Expected $false ` + -Actual (Test-PrIsToolingOnly -Files $srcOnlyFiles) + +# Empty/null files: indeterminate → return false (don't accidentally skip) +Assert-Eq -Label "null file list returns false (cannot decide → leave alone)" -Expected $false ` + -Actual (Test-PrIsToolingOnly -Files $null) +Assert-Eq -Label "empty file list returns false (cannot decide → leave alone)" -Expected $false ` + -Actual (Test-PrIsToolingOnly -Files @()) + +# Edge: file with null path is ignored (count of valid files = 0 → false) +$weirdFiles = @( @{ path = $null; additions = 1 }, @{ path = ''; additions = 1 } ) +Assert-Eq -Label "all-null-path files returns false (no real files counted)" -Expected $false ` + -Actual (Test-PrIsToolingOnly -Files $weirdFiles) + +# Edge: src/.../docs/foo.md should NOT match the docs/ prefix rule +$srcUnderDocsFiles = @( + @{ path = 'src/Controls/docs/api-stability.md'; additions = 5 } +) +Assert-Eq -Label "src/.../docs/ does NOT match top-level docs/ rule" -Expected $false ` + -Actual (Test-PrIsToolingOnly -Files $srcUnderDocsFiles) + +# Edge: eng/something-not-scripts is NOT in the tooling list +$engNotScriptsFiles = @( + @{ path = 'eng/cake/Build.cake'; additions = 5 } +) +Assert-Eq -Label "eng/cake/ is NOT classified as tooling (only eng/scripts/ is)" -Expected $false ` + -Actual (Test-PrIsToolingOnly -Files $engNotScriptsFiles) + +# ───── Classify-RegressionCandidate (contradictory evidence guard) ───── +Write-Host "`n[Unit] Classify-RegressionCandidate (contradictory merged backport)" -ForegroundColor Cyan + +function Get-PrInfo { + param($Repo, $PrNumber) + return [pscustomobject]@{ + number = $PrNumber + title = 'Fix regression' + state = 'MERGED' + baseRefName = 'main' + mergedAt = '2026-01-01T00:00:00Z' + closedAt = '2026-01-01T00:00:00Z' + body = 'Fixes #35000' + mergeCommit = [pscustomobject]@{ oid = 'abc1234def5678' } + files = @([pscustomobject]@{ path = 'src/Core/src/Layouts/Layout.cs'; additions = 1; deletions = 0 }) + } +} + +function Get-BackportPrsForSr { + param($Repo, $SrBranch, $SourcePrNumber) + return @([pscustomobject]@{ + number = 36000 + title = 'Backport fix regression' + state = 'MERGED' + mergedAt = '2026-01-02T00:00:00Z' + closedAt = '2026-01-02T00:00:00Z' + }) +} + +function Test-CommitOnBranch { + param([string]$Sha, [string]$BranchRef) + return $true +} + +$classification = Classify-RegressionCandidate ` + -Issue @{ number = 35000 } ` + -CandidatePrs @(35001) ` + -Ctx @{ repo = 'dotnet/maui'; srBranch = 'release/10.0.1xx-sr7'; mainBranch = 'main' } ` + -SrContents @{ sourcePrs = @(); reverts = @() } + +Assert-Eq -Label "merged backport absent from SR sourcePrSet requires review" ` + -Expected 'needs-human-review' -Actual $classification.classification +Assert-Eq -Label "contradictory merged backport evidence is low confidence" ` + -Expected 'low' -Actual $classification.confidence +Assert-Eq -Label "contradictory evidence explains missing SR git contents" ` + -Expected $true -Actual (($classification.evidence -join "`n") -match 'not found in SR git contents') + +# ───── Bug regression: issue fixed by SR-direct PR (closing keyword on SR commit) ───── +# Real-world case: issue #35756 (TabbedPage modal) was fixed by PR #35768 opened +# directly against release/10.0.1xx-sr7. A later PR #35803 opened against main +# also closes the same issue (forward-flow). The classifier MUST recognize the +# SR commit's closing keyword and classify 'in-sr-active', not 'open-on-main'. +Write-Host "`n[Unit] Classify-RegressionCandidate (issue fixed by SR-direct PR)" -ForegroundColor Cyan + +# Mock: PR #35803 is an OPEN PR against main (the forward-flow companion). The +# classifier would normally pick it up via timeline cross-references and report +# 'open-on-main'. With the fix, the SR-direct fix in srContents.fixedIssues +# takes precedence. +function Get-PrInfo { + param($Repo, $PrNumber) + return [pscustomobject]@{ + number = $PrNumber + title = 'Fix OnNavigatedTo not firing after PopModalAsync' + state = 'OPEN' + baseRefName = 'main' + mergedAt = $null + closedAt = $null + body = 'Fixes #35756' + mergeCommit = $null + files = @([pscustomobject]@{ path = 'src/Controls/src/Core/Page.cs'; additions = 5; deletions = 1 }) + } +} +function Get-BackportPrsForSr { param($Repo, $SrBranch, $SourcePrNumber) return @() } +function Test-CommitOnBranch { param([string]$Sha, [string]$BranchRef) return $false } + +$srContentsWithDirectFix = @{ + sourcePrs = @(35768) + backportPrs = @() + reverts = @() + fixedIssues = @(35756) + commits = @( + @{ + sha = 'ddf238c74fb10bc42b1722495117e216cd43d772' + author = 'praveenkumarkarunanithi' + date = '2026-06-05T17:17:07+05:30' + subject = 'Fix OnNavigatedTo not firing after PopModalAsync (#35768)' + isRevert = $false + backportPr = 35768 + sourcePr = $null + cherrySourceSha = $null + fixedIssues = @(35756) + origin = 'primary' + } + ) +} + +$cls = Classify-RegressionCandidate ` + -Issue @{ number = 35756 } ` + -CandidatePrs @(35803) ` + -Ctx @{ repo = 'dotnet/maui'; srBranch = 'release/10.0.1xx-sr8'; mainBranch = 'main' } ` + -SrContents $srContentsWithDirectFix + +Assert-Eq -Label "SR-direct fix (closing keyword on SR commit) → in-sr-active not open-on-main" ` + -Expected 'in-sr-active' -Actual $cls.classification +Assert-Eq -Label "SR-direct fix → high confidence" ` + -Expected 'high' -Actual $cls.confidence +Assert-Eq -Label "SR-direct fix → evidence cites the SR fix PR (#35768)" ` + -Expected $true -Actual (($cls.evidence -join "`n") -match '#35768') +Assert-Eq -Label "SR-direct fix → candidateFixPrs surfaces the SR PR (not the open main PR)" ` + -Expected 35768 -Actual ([int]$cls.candidateFixPrs[0].number) +Assert-Eq -Label "SR-direct fix → recommendedAction says no action" ` + -Expected $true -Actual ($cls.recommendedAction -match 'No action') + +# Edge: SR-direct fix that was REVERTED on SR should classify as in-sr-reverted +$srContentsWithRevertedFix = @{ + sourcePrs = @(35768) + backportPrs = @() + reverts = @(@{ revertsPr = $null; revertBackportPr = 35768 }) + fixedIssues = @(35756) + commits = @( + @{ backportPr = 35768; sourcePr = $null; fixedIssues = @(35756); isRevert = $false } + ) +} +$clsRev = Classify-RegressionCandidate ` + -Issue @{ number = 35756 } ` + -CandidatePrs @(35803) ` + -Ctx @{ repo = 'dotnet/maui'; srBranch = 'release/10.0.1xx-sr8'; mainBranch = 'main' } ` + -SrContents $srContentsWithRevertedFix + +Assert-Eq -Label "SR-direct fix REVERTED → classified as in-sr-reverted" ` + -Expected 'in-sr-reverted' -Actual $clsRev.classification + +# Edge: backward compat — partial SrContents shape (no .commits field) shouldn't throw +$cls2 = Classify-RegressionCandidate ` + -Issue @{ number = 99999 } ` + -CandidatePrs @() ` + -Ctx @{ repo = 'dotnet/maui'; srBranch = 'release/10.0.1xx-sr8'; mainBranch = 'main' } ` + -SrContents @{ sourcePrs = @(); reverts = @() } +Assert-Eq -Label "Partial SrContents (no commits/fixedIssues) does not throw" ` + -Expected 'no-fix-yet' -Actual $cls2.classification + +# ───── Get-VerdictTier (deterministic tier table) ───── +Write-Host "`n[Unit] Get-VerdictTier (deterministic tier table)" -ForegroundColor Cyan + +foreach ($case in @( + @{ Cls = 'in-sr-reverted'; Tier = 1 } + @{ Cls = 'no-fix-yet'; Tier = 1 } + @{ Cls = 'rejected-from-sr'; Tier = 2 } + @{ Cls = 'backport-in-progress'; Tier = 2 } + @{ Cls = 'merged-on-main-no-backport'; Tier = 2 } + @{ Cls = 'merged-non-main-only'; Tier = 2 } + @{ Cls = 'open-on-main'; Tier = 2 } + @{ Cls = 'needs-human-review'; Tier = 2 } + @{ Cls = 'in-sr-active'; Tier = 3 } + @{ Cls = 'closed-as-duplicate'; Tier = 3 } + @{ Cls = 'out-of-scope-future-sr'; Tier = 3 } + @{ Cls = 'something-unknown'; Tier = 2 } # safe-default: risk +)) { + Assert-Eq -Label "Get-VerdictTier '$($case.Cls)' = $($case.Tier)" ` + -Expected $case.Tier ` + -Actual (Get-VerdictTier -Classification $case.Cls) +} + +# ───── Get-OverallVerdict (the readiness gate) ───── +Write-Host "`n[Unit] Get-OverallVerdict (readiness gate)" -ForegroundColor Cyan + +# Green: nothing bad +$dataGreen = @{ + metadata = @{ mode = 'shipped' } + regressions = @( + @{ classification = 'in-sr-active'; state = 'CLOSED' } + @{ classification = 'closed-as-duplicate'; state = 'CLOSED' } + ) + ci = @{ overall = 'green' } +} +$v = Get-OverallVerdict -Data $dataGreen +Assert-Eq -Label "all clean → 🟢 Ready" -Expected '🟢' -Actual $v.symbol +Assert-Eq -Label "all clean → tier 3" -Expected 3 -Actual $v.tier + +# Yellow: a backport in progress +$dataYellow = @{ + metadata = @{ mode = 'shipped' } + regressions = @( + @{ classification = 'in-sr-active'; state = 'CLOSED' } + @{ classification = 'backport-in-progress'; state = 'OPEN' } + ) + ci = @{ overall = 'green' } +} +$v = Get-OverallVerdict -Data $dataYellow +Assert-Eq -Label "backport-in-progress → 🟡 Conditionally Ready" -Expected '🟡' -Actual $v.symbol + +# Yellow: red-needs-review CI +$dataYellowCi = @{ + metadata = @{ mode = 'shipped' } + regressions = @(@{ classification = 'in-sr-active'; state = 'CLOSED' }) + ci = @{ overall = 'red-needs-review' } +} +$v = Get-OverallVerdict -Data $dataYellowCi +Assert-Eq -Label "red-needs-review (shipped) → 🟡" -Expected '🟡' -Actual $v.symbol + +# Yellow: partial-unknown CI +$dataPartialUnknownCi = @{ + metadata = @{ mode = 'shipped' } + regressions = @(@{ classification = 'in-sr-active'; state = 'CLOSED' }) + ci = @{ overall = 'partial-unknown' } +} +$v = Get-OverallVerdict -Data $dataPartialUnknownCi +Assert-Eq -Label "partial-unknown (shipped) → 🟡" -Expected '🟡' -Actual $v.symbol + +# Red: open no-fix-yet +$dataRedRegr = @{ + metadata = @{ mode = 'shipped' } + regressions = @( + @{ classification = 'in-sr-active'; state = 'CLOSED' } + @{ classification = 'no-fix-yet'; state = 'OPEN' } + ) + ci = @{ overall = 'green' } +} +$v = Get-OverallVerdict -Data $dataRedRegr +Assert-Eq -Label "OPEN no-fix-yet → 🔴" -Expected '🔴' -Actual $v.symbol + +# CLOSED no-fix-yet must NOT block (the issue was triaged away) +$dataClosedNoFix = @{ + metadata = @{ mode = 'shipped' } + regressions = @( + @{ classification = 'no-fix-yet'; state = 'CLOSED' } + ) + ci = @{ overall = 'green' } +} +$v = Get-OverallVerdict -Data $dataClosedNoFix +Assert-Eq -Label "CLOSED no-fix-yet does NOT block → 🟢" -Expected '🟢' -Actual $v.symbol + +# In-sr-reverted always blocks +$dataReverted = @{ + metadata = @{ mode = 'shipped' } + regressions = @(@{ classification = 'in-sr-reverted'; state = 'CLOSED' }) + ci = @{ overall = 'green' } +} +$v = Get-OverallVerdict -Data $dataReverted +Assert-Eq -Label "in-sr-reverted → 🔴" -Expected '🔴' -Actual $v.symbol + +# Candidate mode downgrades CI noise to advisory +$dataCandidateCi = @{ + metadata = @{ mode = 'candidate' } + regressions = @() + ci = @{ overall = 'red-needs-review' } +} +$v = Get-OverallVerdict -Data $dataCandidateCi +Assert-Eq -Label "candidate + red-needs-review does NOT block → 🟢" -Expected '🟢' -Actual $v.symbol + +# Unknown CI in candidate mode is advisory only +$dataCandidateUnknown = @{ + metadata = @{ mode = 'candidate' } + regressions = @() + ci = @{ overall = 'partial-unknown' } +} +$v = Get-OverallVerdict -Data $dataCandidateUnknown +Assert-Eq -Label "candidate + partial-unknown does NOT block → 🟢" -Expected '🟢' -Actual $v.symbol + +# ───── ConvertTo-LinkedSha / ConvertTo-LinkedPr ───── +Write-Host "`n[Unit] Markdown linkification helpers" -ForegroundColor Cyan + +$rurl = 'https://github.com/dotnet/maui' +Assert-Eq -Label "ConvertTo-LinkedSha full SHA → markdown link with 8-char display" ` + -Expected '[`23accba7`](https://github.com/dotnet/maui/commit/23accba79e0f12345678)' ` + -Actual (ConvertTo-LinkedSha -Sha '23accba79e0f12345678' -RepoUrl $rurl) + +Assert-Eq -Label "ConvertTo-LinkedSha short SHA renders as-is in display" ` + -Expected '[`abc1234`](https://github.com/dotnet/maui/commit/abc1234)' ` + -Actual (ConvertTo-LinkedSha -Sha 'abc1234' -RepoUrl $rurl) + +Assert-Eq -Label "ConvertTo-LinkedSha empty SHA returns '?'" -Expected '?' ` + -Actual (ConvertTo-LinkedSha -Sha '' -RepoUrl $rurl) + +Assert-Eq -Label "ConvertTo-LinkedSha no RepoUrl falls back to code-fence" ` + -Expected '`abc1234`' ` + -Actual (ConvertTo-LinkedSha -Sha 'abc1234' -RepoUrl '') + +Assert-Eq -Label "ConvertTo-LinkedPr 35807 → markdown link" ` + -Expected '[#35807](https://github.com/dotnet/maui/pull/35807)' ` + -Actual (ConvertTo-LinkedPr -PrNumber 35807 -RepoUrl $rurl) + +Assert-Eq -Label "ConvertTo-LinkedPr null → em-dash" -Expected '—' ` + -Actual (ConvertTo-LinkedPr -PrNumber $null -RepoUrl $rurl) + +Assert-Eq -Label "ConvertTo-LinkedPr no RepoUrl falls back to '#NNN'" -Expected '#35807' ` + -Actual (ConvertTo-LinkedPr -PrNumber 35807 -RepoUrl '') + +# ───── Get-ReportSemanticHash (idempotency hash) ───── +Write-Host "`n[Unit] Get-ReportSemanticHash (idempotency)" -ForegroundColor Cyan + +$dataA = @{ + metadata = @{ srHeadSha = 'aaaaaaaa1111'; fetchedAt = '2025-01-01T00:00:00Z' } + ci = @{ overall = 'green' } + srContents = @{ sourcePrs = @(35001, 35002, 35003) } + regressions = @( + @{ issue = 35001; classification = 'in-sr-active' } + @{ issue = 35002; classification = 'backport-in-progress' } + ) + openSrPrs = @( @{ number = 35100 } ) +} +$verdictA = @{ symbol = '🟡' } +$hashA = Get-ReportSemanticHash -Data $dataA -Verdict $verdictA +Assert-Eq -Label "Hash is 64-char SHA-256 hex" -Expected 64 -Actual $hashA.Length +Assert-Eq -Label "Hash is lowercase hex chars" -Expected $true ` + -Actual ($hashA -match '^[0-9a-f]{64}$') + +# fetchedAt change → SAME hash (intentionally excluded) +$dataB = @{ + metadata = @{ srHeadSha = 'aaaaaaaa1111'; fetchedAt = '2099-12-31T23:59:59Z' } # different + ci = @{ overall = 'green' } + srContents = @{ sourcePrs = @(35001, 35002, 35003) } + regressions = @( + @{ issue = 35001; classification = 'in-sr-active' } + @{ issue = 35002; classification = 'backport-in-progress' } + ) + openSrPrs = @( @{ number = 35100 } ) +} +$hashB = Get-ReportSemanticHash -Data $dataB -Verdict $verdictA +Assert-Eq -Label "Hash invariant to fetchedAt change" -Expected $hashA -Actual $hashB + +# srHeadSha change → DIFFERENT hash +$dataC = $dataA.Clone() +$dataC['metadata'] = @{ srHeadSha = 'bbbbbbbb2222'; fetchedAt = '2025-01-01T00:00:00Z' } +$hashC = Get-ReportSemanticHash -Data $dataC -Verdict $verdictA +Assert-Eq -Label "Hash changes when srHeadSha changes" -Expected $false -Actual ($hashA -eq $hashC) + +# Regression classification change → DIFFERENT hash +$dataD = $dataA.Clone() +$dataD['regressions'] = @( + @{ issue = 35001; classification = 'in-sr-reverted' } # different! + @{ issue = 35002; classification = 'backport-in-progress' } +) +$hashD = Get-ReportSemanticHash -Data $dataD -Verdict $verdictA +Assert-Eq -Label "Hash changes when classification changes" -Expected $false -Actual ($hashA -eq $hashD) + +# Source PR set change → DIFFERENT hash +$dataE = $dataA.Clone() +$dataE['srContents'] = @{ sourcePrs = @(35001, 35002, 35003, 35004) } +$hashE = Get-ReportSemanticHash -Data $dataE -Verdict $verdictA +Assert-Eq -Label "Hash changes when srContents.sourcePrs changes" -Expected $false -Actual ($hashA -eq $hashE) + +# Verdict change → DIFFERENT hash +$verdictRed = @{ symbol = '🔴' } +$hashF = Get-ReportSemanticHash -Data $dataA -Verdict $verdictRed +Assert-Eq -Label "Hash changes when verdict.symbol changes" -Expected $false -Actual ($hashA -eq $hashF) + +# Same input → SAME hash (determinism) +$hashAgain = Get-ReportSemanticHash -Data $dataA -Verdict $verdictA +Assert-Eq -Label "Hash is deterministic across runs" -Expected $hashA -Actual $hashAgain + +# Order independence: source PRs in different order → SAME hash +$dataReorder = $dataA.Clone() +$dataReorder['srContents'] = @{ sourcePrs = @(35003, 35001, 35002) } # reordered +$hashReorder = Get-ReportSemanticHash -Data $dataReorder -Verdict $verdictA +Assert-Eq -Label "Hash invariant to source-PR order" -Expected $hashA -Actual $hashReorder + +# 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, +# silently defeating the workflow's idempotent no-op (it compares a hash written +# by an earlier process against one computed now). The function must use an +# [ordered] dictionary so JSON key order — and the hash — is stable across +# processes. Same-process re-computation (above) can't catch this because the +# hash seed is fixed within one process; we must compute in fresh child processes. +Write-Host "`n[Unit] Get-ReportSemanticHash cross-process stability" -ForegroundColor Cyan +$childHashScript = @' +$env:GET_RELEASE_READINESS_TEST_MODE = "1" +. (Join-Path $args[0] "Get-ReleaseReadiness.ps1") -SrBranch "release/10.0.1xx-sr1" | Out-Null +$data = @{ + metadata = @{ srHeadSha = "aaaaaaaa1111"; fetchedAt = "2025-01-01T00:00:00Z" } + ci = @{ overall = "green" } + srContents = @{ sourcePrs = @(35001, 35002, 35003) } + regressions = @( + @{ issue = 35001; classification = "in-sr-active" } + @{ issue = 35002; classification = "backport-in-progress" } + ) + openSrPrs = @( @{ number = 35100 } ) + shipChecks = @( @{ Area = "CI"; Status = "GREEN" }, @{ Area = "Milestones"; Status = "WATCH" } ) +} +Write-Output (Get-ReportSemanticHash -Data $data -Verdict @{ symbol = "YELLOW" }) +'@ +$childScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) "rr-hash-child-$([guid]::NewGuid().ToString('N')).ps1" +Set-Content -LiteralPath $childScriptPath -Value $childHashScript -Encoding UTF8 +$rrScriptsDir = Join-Path $PSScriptRoot '..' 'scripts' +try { + $childHash1 = (& pwsh -NoProfile -File $childScriptPath $rrScriptsDir 2>$null | Select-Object -Last 1) + $childHash2 = (& pwsh -NoProfile -File $childScriptPath $rrScriptsDir 2>$null | Select-Object -Last 1) + Assert-Eq -Label "Hash is a 64-char SHA-256 hex (child process)" ` + -Expected $true -Actual ($childHash1 -match '^[0-9a-f]{64}$') + Assert-Eq -Label "Hash is stable across separate processes (ordered keys)" ` + -Expected $childHash1 -Actual $childHash2 +} finally { + Remove-Item -LiteralPath $childScriptPath -ErrorAction SilentlyContinue +} + +# ───── Format-MarkdownReport: tracker markers + linkification + body cap ───── +Write-Host "`n[Unit] Format-MarkdownReport (markers, linkification, cap)" -ForegroundColor Cyan + +$mdData = @{ + metadata = @{ + srBranch = 'release/10.0.1xx-sr7' + srHeadSha = 'aaaaaaaa1111bbbbbbbb2222cccccccc' + srHeadSubject = 'Test commit' + fetchedAt = '2025-01-01T00:00:00Z' + regressionLabels = @('regressed-in-10.0.60', 'regressed-in-10.0.70') + labelInferenceMode = 'explicit' + repo = 'dotnet/maui' + } + warnings = @() + ci = @{ + overall = 'green' + pipelines = @( + @{ name = 'maui-pr'; verdict = 'green'; latestBuild = @{ result = 'succeeded'; isAtOrAheadOfSrHead = $true; id = '12345'; url = 'https://example/12345' } } + ) + } + srContents = @{ commitCount = 5; sourcePrs = @(35001, 35002); reverts = @() } + regressions = @( + @{ issue = 35001; title = 'Bug A'; state = 'CLOSED'; classification = 'in-sr-active'; + candidateFixPrs = @( @{ number = 35100 } ); recommendedAction = 'No action' } + @{ issue = 35002; title = 'Bug B'; state = 'OPEN'; classification = 'backport-in-progress'; + candidateFixPrs = @( @{ number = 35200 } ); recommendedAction = 'Track backport' } + ) + summary = @{ 'in-sr-active' = 1; 'backport-in-progress' = 1 } + openSrPrs = @() +} + +$md = Format-MarkdownReport -Data $mdData -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 + +# Tracker marker (hidden) +Assert-Eq -Label "Body contains tracker marker comment" -Expected $true ` + -Actual ($md -match '') + +# Semantic hash marker (hidden) +Assert-Eq -Label "Body contains semantic-hash marker comment" -Expected $true ` + -Actual ($md -match '') + +# Visible tracker line +Assert-Eq -Label "Body contains visible Tracker: line" -Expected $true ` + -Actual ($md -match '\*\*Tracker:\*\* `net10-sr7`') + +# Verdict appears +Assert-Eq -Label "Body shows 🟡 verdict (backport-in-progress)" -Expected $true ` + -Actual ($md -match 'Verdict — 🟡 \*\*Conditionally Ready\*\*') + +# Tier sections +Assert-Eq -Label "Body has 🔴 Tier 1 section" -Expected $true ` + -Actual ($md -match '🔴 Tier 1') +Assert-Eq -Label "Body has 🟡 Tier 2 section" -Expected $true ` + -Actual ($md -match '🟡 Tier 2') +Assert-Eq -Label "Body has 🟢 Tier 3 section" -Expected $true ` + -Actual ($md -match '🟢 Tier 3') + +# Linkified PR (issue 35001's fix #35100) +Assert-Eq -Label "Body linkifies fix PRs (#35100)" -Expected $true ` + -Actual ($md -match '\[#35100\]\(https://github\.com/dotnet/maui/pull/35100\)') + +# Linkified issue +Assert-Eq -Label "Body linkifies issues (#35001)" -Expected $true ` + -Actual ($md -match '\[#35001\]\(https://github\.com/dotnet/maui/issues/35001\)') + +# Linkified SHA +Assert-Eq -Label "Body linkifies HEAD SHA" -Expected $true ` + -Actual ($md -match '\[`aaaaaaaa`\]\(https://github\.com/dotnet/maui/commit/aaaaaaaa1111') + +# Human-editable section markers +Assert-Eq -Label "Body has human-notes:begin marker" -Expected $true ` + -Actual ($md -match '') +Assert-Eq -Label "Body has human-notes:end marker" -Expected $true ` + -Actual ($md -match '') + +# Without TrackerKey: no tracker marker, no visible Tracker line +$mdNoTracker = Format-MarkdownReport -Data $mdData -RepoUrl 'https://github.com/dotnet/maui' ` + -MaxBodyBytes 60000 +Assert-Eq -Label "Without -TrackerKey: no tracker marker" -Expected $false ` + -Actual ($mdNoTracker -match 'release-readiness-tracker:') +Assert-Eq -Label "Without -TrackerKey: no visible Tracker line" -Expected $false ` + -Actual ($mdNoTracker -match '\*\*Tracker:\*\*') +# Hash marker still present (it's not gated by TrackerKey) +Assert-Eq -Label "Without -TrackerKey: hash marker still present" -Expected $true ` + -Actual ($mdNoTracker -match '$')).Count +$cappedEnd = ([regex]::Matches($mdCapped, '(?m)^$')).Count +Assert-Eq -Label "Truncated body retains exactly one notes:begin marker" -Expected 1 -Actual $cappedBegin +Assert-Eq -Label "Truncated body retains exactly one notes:end marker" -Expected 1 -Actual $cappedEnd +# Hash marker (top of body) also survives truncation, so the SR no-op still works. +Assert-Eq -Label "Truncated body retains the semantic-hash marker" -Expected $true ` + -Actual ($mdCapped -match '') + +# ───── UTF-8 boundary repair: truncation must never split a multibyte char ───── +# Regression for the boundary-repair fix. A naive "trim trailing continuation +# bytes" cut leaves an orphan multibyte LEAD byte (and even strips a COMPLETE +# trailing char down to its lead), which GetString() renders as U+FFFD. That +# replacement char then re-encodes to 3 bytes, pushing the body back over the +# cap. Stuff the HEAD subject (rendered near the top of the body) with 4-byte +# chars, sweep caps so the cut lands inside that run at every byte phase, and +# assert no replacement char ever appears and the cap is never exceeded. +Write-Host "`n[Unit] UTF-8 boundary repair on truncation" -ForegroundColor Cyan +$origSubject = $mdData.metadata.srHeadSubject +$mdData.metadata.srHeadSubject = ([string][char]::ConvertFromUtf32(0x1F30D)) * 250 # globe x250 +$replacementChar = [char]0xFFFD +$boundaryBad = 0 +$capBusted = 0 +foreach ($cap in 700..790) { + $swept = Format-MarkdownReport -Data $mdData -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes $cap + if ($swept.Contains($replacementChar)) { $boundaryBad++ } + if ([System.Text.Encoding]::UTF8.GetByteCount($swept) -gt $cap) { $capBusted++ } +} +$mdData.metadata.srHeadSubject = $origSubject +Assert-Eq -Label "No U+FFFD across cap sweep (multibyte boundary)" -Expected 0 -Actual $boundaryBad +Assert-Eq -Label "Cap never exceeded across multibyte sweep" -Expected 0 -Actual $capBusted + +# ───── Verdict idempotency: same input → same hash → tracker survives re-runs ───── +Write-Host "`n[Unit] Verdict + hash idempotency (workflow re-run)" -ForegroundColor Cyan + +$md1 = Format-MarkdownReport -Data $mdData -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +$md2 = Format-MarkdownReport -Data $mdData -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +$hash1 = if ($md1 -match '') { $Matches[1] } else { $null } +$hash2 = if ($md2 -match '') { $Matches[1] } else { $null } +Assert-Eq -Label "Re-running with same data produces same semantic hash" ` + -Expected $hash1 -Actual $hash2 + +# Change just the fetchedAt timestamp → hash stays the same +$mdDataNewTime = @{} + $mdData +$mdDataNewTime['metadata'] = @{} + $mdData.metadata +$mdDataNewTime['metadata']['fetchedAt'] = '2099-01-01T00:00:00Z' +$md3 = Format-MarkdownReport -Data $mdDataNewTime -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +$hash3 = if ($md3 -match '') { $Matches[1] } else { $null } +Assert-Eq -Label "Hash stable across only-timestamp re-runs (idempotent posts)" ` + -Expected $hash1 -Actual $hash3 + +# ───── @-mention defang: tracker issues must never tag real users ───── +Write-Host "`n[Unit] @-mention defang (no real-user tagging in tracker issues)" -ForegroundColor Cyan + +# Format-GitHubHandle helper — exercises the at-emit-time defense +Assert-Eq -Label "Format-GitHubHandle: regular login wrapped in backticks" ` + -Expected '`jfversluis`' -Actual (Format-GitHubHandle -Login 'jfversluis') +Assert-Eq -Label "Format-GitHubHandle: bot/app ref preserved + wrapped" ` + -Expected '`app/dotnet-maestro`' -Actual (Format-GitHubHandle -Login 'app/dotnet-maestro') +Assert-Eq -Label "Format-GitHubHandle: strips leading @ before wrapping" ` + -Expected '`mattleibow`' -Actual (Format-GitHubHandle -Login '@mattleibow') +Assert-Eq -Label "Format-GitHubHandle: empty login → fallback" ` + -Expected 'unknown' -Actual (Format-GitHubHandle -Login '') +Assert-Eq -Label "Format-GitHubHandle: null login → fallback" ` + -Expected 'unknown' -Actual (Format-GitHubHandle -Login $null) +Assert-Eq -Label "Format-GitHubHandle: custom fallback honored" ` + -Expected 'n/a' -Actual (Format-GitHubHandle -Login '' -Fallback 'n/a') + +# Safety-net regex: even if a PR title or commit subject contains `@user`, +# the final rendered body must defang it. Inject a hostile title via openSrPrs. +$mdDataWithAt = @{} + $mdData +$mdDataWithAt['openSrPrs'] = @( + @{ + number = 99001 + title = '[BUG] CC @maintainer please review @another/user soon' + author = @{ login = 'jfversluis' } + isDraft = $false + reviewDecision = 'APPROVED' + updatedAt = '2025-01-01T00:00:00Z' + } +) +$mdWithAt = Format-MarkdownReport -Data $mdDataWithAt -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 + +# Find any bare @-mentions that survived (i.e. @-followed-by-username NOT inside backticks) +$bareMentionPattern = '(^|[^a-zA-Z0-9/`])@([a-zA-Z0-9][a-zA-Z0-9_-]*(?:/[a-zA-Z0-9][a-zA-Z0-9_-]*)?)' +$bareMatches = [regex]::Matches($mdWithAt, $bareMentionPattern) +Assert-Eq -Label "Safety net: zero bare @-mentions in rendered body even with hostile title" ` + -Expected 0 -Actual $bareMatches.Count + +# Specific assertions: every hostile mention got backticked +Assert-Eq -Label "Hostile PR title: @maintainer defanged to `maintainer`" -Expected $true ` + -Actual ($mdWithAt -match '`maintainer`') +Assert-Eq -Label "Hostile PR title: @another/user defanged to `another/user`" -Expected $true ` + -Actual ($mdWithAt -match '`another/user`') +Assert-Eq -Label "Author column also defanged (no bare @jfversluis)" -Expected $true ` + -Actual ($mdWithAt -match '`jfversluis`') + +# ───── Candidate-mode open-PR collapse: avoid noisy main-PR dump ───── +Write-Host "`n[Unit] Candidate-mode open-PR collapse (link to candidate PR only)" -ForegroundColor Cyan + +# Shipped-mode (live SR) baseline: full table renders, all rows present. +$mdDataShipped = @{} + $mdData +$mdDataShipped['metadata'] = @{} + $mdData.metadata +$mdDataShipped['metadata']['mode'] = 'shipped' +$mdDataShipped['openSrPrs'] = @( + @{ number = 1001; title = 'Backport: fix A'; author = @{ login = 'alice' }; isDraft = $false; reviewDecision = 'APPROVED'; updatedAt = '2026-06-01T00:00:00Z' } + @{ number = 1002; title = 'Backport: fix B'; author = @{ login = 'bob' }; isDraft = $false; reviewDecision = 'REVIEW_REQUIRED'; updatedAt = '2026-06-02T00:00:00Z' } +) +$mdShipped = Format-MarkdownReport -Data $mdDataShipped -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "Shipped mode: full 'Open PRs Targeting' header still emitted" -Expected $true ` + -Actual ($mdShipped -match 'Open PRs Targeting release/10.0.1xx-sr7 — 2') +Assert-Eq -Label "Shipped mode: full table renders both rows" -Expected $true ` + -Actual (($mdShipped -match '\| \[#1001\]') -and ($mdShipped -match '\| \[#1002\]')) +Assert-Eq -Label "Shipped mode: NO 'Candidate PR for next SR cut' heading" -Expected $false ` + -Actual ($mdShipped -match 'Candidate PR for next SR cut') + +# Candidate mode with NO candidate PR: emit explanatory note, suppress full table. +$mdDataCandNone = @{} + $mdData +$mdDataCandNone['metadata'] = @{} + $mdData.metadata +$mdDataCandNone['metadata']['mode'] = 'candidate' +$mdDataCandNone['metadata']['priorSrBranch'] = 'release/10.0.1xx-sr7' +$mdDataCandNone['metadata']['srBranch'] = 'main' +$mdDataCandNone['openSrPrs'] = @( + @{ number = 2001; title = 'Random WIP fix'; author = @{ login = 'alice' }; isDraft = $false; reviewDecision = 'REVIEW_REQUIRED'; updatedAt = '2026-06-01T00:00:00Z' } + @{ number = 2002; title = 'Bump dependencies'; author = @{ login = 'bob' }; isDraft = $false; reviewDecision = 'APPROVED'; updatedAt = '2026-06-02T00:00:00Z' } +) +$mdCandNone = Format-MarkdownReport -Data $mdDataCandNone -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr8' -MaxBodyBytes 60000 +Assert-Eq -Label "Candidate (no candidate PR): heading is 'Candidate PR for next SR cut'" -Expected $true ` + -Actual ($mdCandNone -match 'Candidate PR for next SR cut') +Assert-Eq -Label "Candidate (no candidate PR): explanatory note rendered" -Expected $true ` + -Actual ($mdCandNone -match 'No open PR titled') +Assert-Eq -Label "Candidate (no candidate PR): noisy PR rows NOT rendered" -Expected $false ` + -Actual (($mdCandNone -match '\| \[#2001\]') -or ($mdCandNone -match '\| \[#2002\]')) +Assert-Eq -Label "Candidate (no candidate PR): old 'Open PRs Targeting' header NOT emitted" -Expected $false ` + -Actual ($mdCandNone -match 'Open PRs Targeting main') + +# Candidate mode WITH a candidate PR: emit single link + omit full table. +$mdDataCandFound = @{} + $mdData +$mdDataCandFound['metadata'] = @{} + $mdData.metadata +$mdDataCandFound['metadata']['mode'] = 'candidate' +$mdDataCandFound['metadata']['priorSrBranch'] = 'release/10.0.1xx-sr8' +$mdDataCandFound['metadata']['srBranch'] = 'main' +$mdDataCandFound['openSrPrs'] = @( + @{ number = 3001; title = 'Random WIP fix'; author = @{ login = 'alice' }; isDraft = $false; reviewDecision = 'REVIEW_REQUIRED'; updatedAt = '2026-06-01T00:00:00Z' } + @{ number = 3002; title = 'June 8th, Candidate'; author = @{ login = 'PureWeen' }; isDraft = $false; reviewDecision = 'REVIEW_REQUIRED'; updatedAt = '2026-06-08T00:00:00Z' } + @{ number = 3003; title = 'Unrelated noise'; author = @{ login = 'bob' }; isDraft = $false; reviewDecision = 'APPROVED'; updatedAt = '2026-06-02T00:00:00Z' } +) +$mdCandFound = Format-MarkdownReport -Data $mdDataCandFound -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr9' -MaxBodyBytes 60000 +Assert-Eq -Label "Candidate (found): heading is 'Candidate PR for next SR cut'" -Expected $true ` + -Actual ($mdCandFound -match 'Candidate PR for next SR cut') +Assert-Eq -Label "Candidate (found): linked the actual candidate PR (#3002)" -Expected $true ` + -Actual ($mdCandFound -match '\[#3002\]\(https://github.com/dotnet/maui/pull/3002\)') +Assert-Eq -Label "Candidate (found): author defanged in link line" -Expected $true ` + -Actual ($mdCandFound -match '`PureWeen`') +Assert-Eq -Label "Candidate (found): unrelated PRs (#3001, #3003) NOT listed" -Expected $false ` + -Actual (($mdCandFound -match '\| \[#3001\]') -or ($mdCandFound -match '\| \[#3003\]')) +Assert-Eq -Label "Candidate (found): pointer to full PR list rendered" -Expected $true ` + -Actual ($mdCandFound -match 'is%3Apr\+is%3Aopen\+base%3Amain') + +# ───── Ship-readiness checks: blocking summary + table ───── +Write-Host "`n[Unit] Ship-readiness checks (versions.props + bug template)" -ForegroundColor Cyan + +# Baseline: no shipChecks key → empty blocking summary, no table +$mdNoShipChecks = Format-MarkdownReport -Data $mdData -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "No shipChecks key: still emits '🟢 No blocking items' (only Tier 1 regressions matter)" -Expected $true ` + -Actual ($mdNoShipChecks -match '🟢 No blocking items') +Assert-Eq -Label "No shipChecks key: no Ship-readiness checks table" -Expected $false ` + -Actual ($mdNoShipChecks -match 'Ship-readiness checks') + +# Single BLOCKED ship check +$mdDataBlocked = @{} + $mdData +$mdDataBlocked['shipChecks'] = @( + [PSCustomObject]@{ + Area = 'versions.props PatchVersion' + Status = 'BLOCKED' + Details = "Current PatchVersion 80 is below expected range [90..99] for SR9" + NextAction = "Bump in eng/Versions.props on main from 80 to 90" + }, + [PSCustomObject]@{ + Area = 'Bug-report template version dropdown' + Status = 'READY' + Details = "Found 10.0.71 in version-with-bug dropdown" + NextAction = 'None' + } +) +$mdBlocked = Format-MarkdownReport -Data $mdDataBlocked -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr9' -MaxBodyBytes 60000 +Assert-Eq -Label "BLOCKED ship check: blocking summary header reflects count" -Expected $true ` + -Actual ($mdBlocked -match '🔴 Blocking — \d+ item') +Assert-Eq -Label "BLOCKED ship check: blocking summary mentions versions.props area" -Expected $true ` + -Actual ($mdBlocked -match '🛠️ versions.props PatchVersion') +Assert-Eq -Label "BLOCKED ship check: blocking summary contains the next-action text" -Expected $true ` + -Actual ($mdBlocked -match 'Bump ') +Assert-Eq -Label "BLOCKED ship check: full Ship-readiness checks table emitted" -Expected $true ` + -Actual ($mdBlocked -match 'Ship-readiness checks') +Assert-Eq -Label "BLOCKED ship check: table shows READY entry for bug template (transparency)" -Expected $true ` + -Actual ($mdBlocked -match 'Bug-report template[^|]*\|\s*🟢 READY') +# Extract just the blocking-summary section (from its heading to the next ## heading) +# and assert it does NOT mention the READY check. +$blockingSection = if ($mdBlocked -match '(?s)## 🔴 Blocking[^\n]*\n(.*?)\n## ') { $Matches[1] } else { '' } +Assert-Eq -Label "READY ship check: NOT listed in blocking summary section" -Expected $false ` + -Actual ($blockingSection -match 'Bug-report template') + +# Only READY ship checks → 🟢 No blocking items (when no Tier 1 regressions) +$mdDataReady = @{} + $mdData +$mdDataReady['shipChecks'] = @( + [PSCustomObject]@{ + Area = 'versions.props'; Status = 'READY'; + Details = 'PatchVersion=71 in expected range [70..79] for SR7'; + NextAction = 'None' + } +) +$mdReady = Format-MarkdownReport -Data $mdDataReady -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "All ship checks READY (no Tier 1 regressions): '🟢 No blocking items'" -Expected $true ` + -Actual ($mdReady -match '🟢 No blocking items') + +# Hash includes shipChecks state (changing a ship check status flips the hash) +$h1 = if ($mdReady -match '') { $Matches[1] } else { $null } +$h2 = if ($mdBlocked -match '') { $Matches[1] } else { $null } +Assert-Eq -Label "Hash changes when a ship check flips from READY → BLOCKED" -Expected $true ` + -Actual ($h1 -and $h2 -and $h1 -ne $h2) + +# Get-OverallVerdict: BLOCKED ship check forces Not Ready +Write-Host "`n[Unit] Get-OverallVerdict — BLOCKED ship checks force Not Ready" -ForegroundColor Cyan + +$verdictData = @{ + metadata = @{ mode = 'shipped' } + regressions = @() + ci = @{ overall = 'green' } + shipChecks = @( + [PSCustomObject]@{ Area = 'versions.props'; Status = 'BLOCKED'; Details = 'patch not bumped'; NextAction = 'bump it' } + ) +} +$verdict = Get-OverallVerdict -Data $verdictData +Assert-Eq -Label "Verdict tier 1 when shipChecks contains BLOCKED entry" -Expected 1 -Actual $verdict.tier +Assert-Eq -Label "Verdict label is 'Not Ready'" -Expected 'Not Ready' -Actual $verdict.label +Assert-Eq -Label "Verdict reasons list mentions BLOCKED ship-check area" -Expected $true ` + -Actual ([bool](@($verdict.reasons) -match 'Ship check BLOCKED: versions\.props')) + +# WATCH or READY ship checks must not escalate +$verdictDataReady = @{ + metadata = @{ mode = 'shipped' } + regressions = @() + ci = @{ overall = 'green' } + shipChecks = @( + [PSCustomObject]@{ Area = 'versions.props'; Status = 'READY'; Details = 'OK'; NextAction = 'None' } + ) +} +$verdictReadyResult = Get-OverallVerdict -Data $verdictDataReady +Assert-Eq -Label "READY-only ship checks: verdict stays at tier 3 (Ready)" -Expected 3 -Actual $verdictReadyResult.tier + +# CLEANUP ship checks must surface in the report but MUST NOT escalate the verdict. +# This locks the contract: CLEANUP = "housekeeping that needs doing, but doesn't +# prevent shipping". Used for stale-milestone backlog, missing bug-template entry, etc. +$verdictDataCleanup = @{ + metadata = @{ mode = 'shipped' } + regressions = @() + ci = @{ overall = 'green' } + shipChecks = @( + [PSCustomObject]@{ Area = 'Stale open milestones (2)'; Status = 'CLEANUP'; Details = 'SR6+SR7 still open'; NextAction = 'triage' } + [PSCustomObject]@{ Area = 'Bug template lists SR8 version'; Status = 'CLEANUP'; Details = 'missing 10.0.80 entry'; NextAction = 'add entry' } + ) +} +$verdictCleanupResult = Get-OverallVerdict -Data $verdictDataCleanup +Assert-Eq -Label "CLEANUP-only ship checks: verdict stays at tier 3 (Ready)" -Expected 3 -Actual $verdictCleanupResult.tier +Assert-Eq -Label "CLEANUP-only ship checks: no Tier 1 reason about BLOCKED ship check" -Expected $false ` + -Actual ([bool](@($verdictCleanupResult.reasons) -match 'Ship check BLOCKED')) + +# Markdown rendering: CLEANUP renders a separate '🧹 Cleanup follow-ups' section +# and stays out of the '🔴 Blocking' table. +$mdDataCleanup = @{} + $mdData +$mdDataCleanup['shipChecks'] = @( + [PSCustomObject]@{ Area = 'Stale open milestones (2)'; Status = 'CLEANUP'; Details = 'SR6+SR7 open'; NextAction = 'triage' } +) +$mdCleanup = Format-MarkdownReport -Data $mdDataCleanup -RepoUrl 'https://github.com/dotnet/maui' +Assert-Eq -Label "CLEANUP renders dedicated '🧹 Cleanup follow-ups' section" -Expected $true ` + -Actual ($mdCleanup -match '## 🧹 Cleanup follow-ups') +Assert-Eq -Label "CLEANUP does NOT appear in '🔴 Blocking' table" -Expected $false ` + -Actual ($mdCleanup -match '🔴 Blocking[\s\S]*Stale open milestones') +Assert-Eq -Label "CLEANUP renders '🧹 CLEANUP' badge in full ship-checks table" -Expected $true ` + -Actual ($mdCleanup -match '🧹 CLEANUP') + +# ───── Open Fix PRs Inbound — hoisted regression-fix watchlist ───── +Write-Host "`n[Unit] Open Fix PRs Inbound (hoisted regression-fix watchlist)" -ForegroundColor Cyan + +# Two open-on-main + one backport-in-progress = 3 rows; one in-sr-active filtered out +$mdDataInbound = @{} + $mdData +$mdDataInbound['metadata'] = @{} + $mdData.metadata +$mdDataInbound['metadata']['srBranch'] = 'release/10.0.1xx-sr8' +$mdDataInbound['regressions'] = @( + @{ issue = 9001; title = 'Open-on-main regression 1'; state = 'OPEN' + classification = 'open-on-main'; confidence = 'high'; evidence = @() + candidateFixPrs = @( + @{ number = 4001; title = 'Fix 9001'; state = 'OPEN'; baseRef = 'main'; onMain = $false; backports = @() } + ) + recommendedAction = 'Wait for main merge, then open backport' } + @{ issue = 9002; title = 'Open-on-main regression 2 with very long title that should be truncated when rendered to keep the column readable' + state = 'OPEN' + classification = 'open-on-main'; confidence = 'high'; evidence = @() + candidateFixPrs = @( + @{ number = 4002; title = 'Fix 9002'; state = 'OPEN'; baseRef = 'main'; onMain = $false; backports = @() } + ) + recommendedAction = 'Wait for main merge, then open backport' } + @{ issue = 9003; title = 'Backport-in-progress regression'; state = 'OPEN' + classification = 'backport-in-progress'; confidence = 'high'; evidence = @() + candidateFixPrs = @( + @{ number = 4003; title = 'Fix 9003'; state = 'MERGED'; baseRef = 'main'; onMain = $true + backports = @( + @{ number = 4099; state = 'OPEN'; title = 'Backport: fix 9003' } + ) } + ) + recommendedAction = 'Track backport PR to completion' } + @{ issue = 9004; title = 'Already shipped regression'; state = 'CLOSED' + classification = 'in-sr-active'; confidence = 'high'; evidence = @() + candidateFixPrs = @() + recommendedAction = 'No action — fix is shipping' } +) +$mdInbound = Format-MarkdownReport -Data $mdDataInbound -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr8' -MaxBodyBytes 60000 + +Assert-Eq -Label "Open Fix PRs Inbound: section header emitted with count 3" -Expected $true ` + -Actual ($mdInbound -match '## 📥 Open Fix PRs Inbound — 3 PR\(s\)') +# Extract just the inbound section so we can check what's inside it +# (other PR/issue numbers like #4003, #9004 legitimately appear in the lower +# regression breakdown tables — they're just not allowed in the Inbound row set). +$inboundSection = if ($mdInbound -match '(?s)## 📥 Open Fix PRs Inbound[^\n]*\n(.*?)\n## ') { $Matches[1] } else { '' } +Assert-Eq -Label "Open Fix PRs Inbound: links open-on-main PR #4001" -Expected $true ` + -Actual ($inboundSection -match '\[#4001\]\(https://github.com/dotnet/maui/pull/4001\)') +Assert-Eq -Label "Open Fix PRs Inbound: links open-on-main PR #4002" -Expected $true ` + -Actual ($inboundSection -match '\[#4002\]\(https://github.com/dotnet/maui/pull/4002\)') +Assert-Eq -Label "Open Fix PRs Inbound: links backport-in-progress PR #4099 (not source #4003)" -Expected $true ` + -Actual (($inboundSection -match '\[#4099\]') -and -not ($inboundSection -match '\[#4003\]')) +Assert-Eq -Label "Open Fix PRs Inbound: in-sr-active regression (#9004) NOT listed in Inbound rows" -Expected $false ` + -Actual ($inboundSection -match '#9004') +Assert-Eq -Label "Open Fix PRs Inbound: status column distinguishes main vs SR" -Expected $true ` + -Actual (($inboundSection -match '🔵 OPEN — awaiting main merge') -and ($inboundSection -match '🟡 backport OPEN on SR')) +Assert-Eq -Label "Open Fix PRs Inbound: long titles truncated at 70 chars" -Expected $true ` + -Actual ($inboundSection -match 'Open-on-main regression 2[^|]*\.\.\.') + +# Section is appended ABOVE Ship-readiness checks (just under Blocking) +$inboundIdx = $mdInbound.IndexOf('## 📥 Open Fix PRs Inbound') +$shipChecksIdx = $mdInbound.IndexOf('## Ship-readiness checks') +$blockingIdx = if ($mdInbound -match '(?m)^## (?:🔴 Blocking|🟢 No blocking)') { $mdInbound.IndexOf($Matches[0]) } else { -1 } +Assert-Eq -Label "Open Fix PRs Inbound: appears AFTER Blocking section" -Expected $true ` + -Actual ($blockingIdx -ge 0 -and $inboundIdx -gt $blockingIdx) +Assert-Eq -Label "Open Fix PRs Inbound: appears BEFORE Ship-readiness checks" -Expected $true ` + -Actual ($shipChecksIdx -lt 0 -or $inboundIdx -lt $shipChecksIdx) + +# Empty case: no regressions in flight → no section +$mdDataNoInbound = @{} + $mdData +$mdDataNoInbound['regressions'] = @( + @{ issue = 9005; title = 'no-fix-yet'; state = 'OPEN'; classification = 'no-fix-yet' + confidence = 'high'; evidence = @(); candidateFixPrs = @(); recommendedAction = 'investigate' } +) +$mdNoInbound = Format-MarkdownReport -Data $mdDataNoInbound -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr8' -MaxBodyBytes 60000 +Assert-Eq -Label "Open Fix PRs Inbound: no section when no open fix PRs in flight" -Expected $false ` + -Actual ($mdNoInbound -match 'Open Fix PRs Inbound') + +# ───── Get-ReleaseShipChecks: 'Main bumped to next SR cycle' check ───── +# Verifies that when surveying an in-flight SR, the script ALSO blocks if +# main hasn't bumped its PatchVersion past the SR being shipped. (Convention: +# right after release/X.Y.Zxx-srN is cut, main bumps to (N+1)*10 so any PR +# merging during SR$N stabilization correctly targets the NEXT SR cycle.) +Write-Host "`n[Unit] Get-ReleaseShipChecks — 'Main bumped to next SR cycle'" -ForegroundColor Cyan + +function Build-VersionsPropsXml { + param( + [int]$Major, + [int]$Minor, + [int]$Patch, + # Optional servicing-flip fields. When $null, the element is omitted + # (mirrors a freshly-cut SR branch that hasn't been flipped yet). + [string]$PreReleaseVersionLabel, + [string]$StabilizePackageVersion + ) + $labelLine = if ($PreReleaseVersionLabel) { + " $PreReleaseVersionLabel`n" + } else { "" } + $stabilizeLine = if ($StabilizePackageVersion) { + " $StabilizePackageVersion`n" + } else { "" } + @" + + + $Major + $Minor + $Patch +$labelLine$stabilizeLine + +"@ +} + +# Tiny bug-report.yml that always satisfies the version-with-bug dropdown check +# (we're focused on the new main-bumped check, not the template check). +$bugYamlAllowsAll = @' +- type: dropdown + id: version-with-bug + attributes: + options: + - "10.0.80 (SR8)" + - "10.0.90 (SR9)" +'@ + +function Invoke-ShipChecksWithMockedVersions { + param( + [hashtable]$SrVersion, # @{Major;Minor;Patch [;PreReleaseVersionLabel;StabilizePackageVersion]} for the SR branch + [hashtable]$MainVersion, # @{Major;Minor;Patch [;PreReleaseVersionLabel;StabilizePackageVersion]} for main + [string]$SrBranch = 'release/10.0.1xx-sr8', + [string]$MainBranch = 'main', + [switch]$Candidate + ) + # Wrap Get-FileFromRef so the script's existing Get-VersionsPropsState / + # Get-BugTemplateVersions read from these in-memory blobs. + $srRef = "origin/$SrBranch" + $mainRef = "origin/$MainBranch" + $srXml = Build-VersionsPropsXml @SrVersion + $mainXml = if ($MainVersion) { Build-VersionsPropsXml @MainVersion } else { $null } + + $script:_origGetFile = Get-Command Get-FileFromRef -CommandType Function + function global:Get-FileFromRef { + param([string]$Path, [string]$Ref) + if ($Path -eq 'eng/Versions.props') { + if ($Ref -eq $script:_mockSrRef) { return $script:_mockSrXml } + if ($Ref -eq $script:_mockMainRef) { return $script:_mockMainXml } + return $null + } + if ($Path -eq '.github/ISSUE_TEMPLATE/bug-report.yml') { + return $script:_mockBugYaml + } + return $null + } + $script:_mockSrRef = $srRef + $script:_mockMainRef = $mainRef + $script:_mockSrXml = $srXml + $script:_mockMainXml = $mainXml + $script:_mockBugYaml = $bugYamlAllowsAll + + try { + $ctx = @{ + srBranch = if ($Candidate) { $MainBranch } else { $SrBranch } + srRef = if ($Candidate) { "origin/$MainBranch" } else { "origin/$SrBranch" } + mainBranch = $MainBranch + mode = if ($Candidate) { 'candidate' } else { 'in-flight' } + priorSrBranch = if ($Candidate) { $SrBranch } else { $null } + } + return Get-ReleaseShipChecks -Ctx $ctx + } finally { + Remove-Item function:global:Get-FileFromRef -ErrorAction SilentlyContinue + } +} + +# Helper: scoped check lookup +function Get-CheckByAreaPrefix { + param($Checks, [string]$Prefix) + @($Checks | Where-Object { $_.Area.StartsWith($Prefix) }) | Select-Object -First 1 +} + +# Scenario 1: SR8 in-flight, main STILL at same cycle (10.0.80) — BLOCKED +$checks1 = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80 } ` + -MainVersion @{ Major=10; Minor=0; Patch=80 } ` + -SrBranch 'release/10.0.1xx-sr8' + +$mainBumpCheck = Get-CheckByAreaPrefix -Checks $checks1 -Prefix 'Main bumped to SR9 cycle' +Assert-Eq -Label "Main-not-bumped: emits 'Main bumped to SR9 cycle' check" -Expected $true ` + -Actual ($null -ne $mainBumpCheck) +Assert-Eq -Label "Main-not-bumped (main=80, SR8=80): status BLOCKED" -Expected 'BLOCKED' -Actual $mainBumpCheck.Status +Assert-Eq -Label "Main-not-bumped: details mention same cycle" -Expected $true ` + -Actual ([bool]($mainBumpCheck.Details -match 'same cycle')) +Assert-Eq -Label "Main-not-bumped: next action points to 90" -Expected $true ` + -Actual ([bool]($mainBumpCheck.NextAction -match '\b90\b')) + +# Scenario 2: SR8 in-flight, main already bumped to 10.0.90 — READY +$checks2 = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80 } ` + -MainVersion @{ Major=10; Minor=0; Patch=90 } ` + -SrBranch 'release/10.0.1xx-sr8' + +$mainBumpCheck2 = Get-CheckByAreaPrefix -Checks $checks2 -Prefix 'Main bumped to SR9 cycle' +Assert-Eq -Label "Main-bumped-to-90: status READY" -Expected 'READY' -Actual $mainBumpCheck2.Status +Assert-Eq -Label "Main-bumped-to-90: details show 90 satisfied" -Expected $true ` + -Actual ([bool]($mainBumpCheck2.Details -match 'at or past')) + +# Scenario 3: SR8 in-flight, main past the major train (11.0.x) — READY +$checks3 = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80 } ` + -MainVersion @{ Major=11; Minor=0; Patch=10 } ` + -SrBranch 'release/10.0.1xx-sr8' + +$mainBumpCheck3 = Get-CheckByAreaPrefix -Checks $checks3 -Prefix 'Main bumped to SR9 cycle' +Assert-Eq -Label "Main-past-major (11.0): status READY" -Expected 'READY' -Actual $mainBumpCheck3.Status +Assert-Eq -Label "Main-past-major: details mention moved past train" -Expected $true ` + -Actual ([bool]($mainBumpCheck3.Details -match 'moved past')) + +# Scenario 4: SR8 in-flight, main bumped multiple cycles ahead (10.0.110 for hypothetical SR11) — READY +$checks4 = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80 } ` + -MainVersion @{ Major=10; Minor=0; Patch=110 } ` + -SrBranch 'release/10.0.1xx-sr8' + +$mainBumpCheck4 = Get-CheckByAreaPrefix -Checks $checks4 -Prefix 'Main bumped to SR9 cycle' +Assert-Eq -Label "Main-way-ahead (patch=110): status READY" -Expected 'READY' -Actual $mainBumpCheck4.Status + +# Scenario 5: Candidate mode → the new check is SKIPPED (no double-counting with the +# existing 'Versions.props bump (main → SRn)' check that already targets main) +$checks5 = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80 } ` + -MainVersion @{ Major=10; Minor=0; Patch=80 } ` + -SrBranch 'release/10.0.1xx-sr8' ` + -Candidate + +$mainBumpCheck5 = Get-CheckByAreaPrefix -Checks $checks5 -Prefix 'Main bumped to' +Assert-Eq -Label "Candidate mode: 'Main bumped to' check NOT emitted (avoids redundancy)" -Expected $true ` + -Actual ($null -eq $mainBumpCheck5) + +# Scenario 6: SR-branch check still works (existing behavior — guard against regressions) +$srBranchCheck = Get-CheckByAreaPrefix -Checks $checks1 -Prefix 'Versions.props bump (SR8)' +Assert-Eq -Label "Existing SR-branch check still emitted alongside new main-bump check" -Expected $true ` + -Actual ($null -ne $srBranchCheck) +Assert-Eq -Label "Existing SR-branch check stays READY when SR is at 80" -Expected 'READY' -Actual $srBranchCheck.Status + +# ───── Get-ReleaseShipChecks: 'Servicing-release flip' check ───── +# When an SR branch is cut from main, eng/Versions.props MUST be flipped to +# servicing-release mode (PreReleaseVersionLabel=servicing, StabilizePackageVersion=true). +# Without it, the SR builds prerelease packages and never ships as stable — +# CI stays green so nothing else catches it. +Write-Host "`n[Unit] Get-ReleaseShipChecks — 'Servicing-release flip'" -ForegroundColor Cyan + +# Scenario A: SR8 fully flipped — READY +$flipChecksA = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80; PreReleaseVersionLabel='servicing'; StabilizePackageVersion='true' } ` + -MainVersion @{ Major=10; Minor=0; Patch=90; PreReleaseVersionLabel='ci.main'; StabilizePackageVersion='false' } ` + -SrBranch 'release/10.0.1xx-sr8' +$flipCheckA = Get-CheckByAreaPrefix -Checks $flipChecksA -Prefix 'Versions.props servicing flip (SR8)' +Assert-Eq -Label "Flip-applied: emits 'Versions.props servicing flip (SR8)' check" -Expected $true ` + -Actual ($null -ne $flipCheckA) +Assert-Eq -Label "Flip-applied (servicing + true): status READY" -Expected 'READY' -Actual $flipCheckA.Status + +# Scenario B: SR8 with label still ci.main — BLOCKED +$flipChecksB = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80; PreReleaseVersionLabel='ci.main'; StabilizePackageVersion='true' } ` + -MainVersion @{ Major=10; Minor=0; Patch=90 } ` + -SrBranch 'release/10.0.1xx-sr8' +$flipCheckB = Get-CheckByAreaPrefix -Checks $flipChecksB -Prefix 'Versions.props servicing flip (SR8)' +Assert-Eq -Label "Flip-missing-label (ci.main): status BLOCKED" -Expected 'BLOCKED' -Actual $flipCheckB.Status +Assert-Eq -Label "Flip-missing-label: details mention PreReleaseVersionLabel" -Expected $true ` + -Actual ([bool]($flipCheckB.Details -match 'PreReleaseVersionLabel')) +Assert-Eq -Label "Flip-missing-label: details mention actual ci.main value" -Expected $true ` + -Actual ([bool]($flipCheckB.Details -match 'ci\.main')) +Assert-Eq -Label "Flip-missing-label: details do NOT flag StabilizePackageVersion" -Expected $true ` + -Actual (-not ($flipCheckB.Details -match 'StabilizePackageVersion')) + +# Scenario C: SR8 with StabilizePackageVersion=false — BLOCKED +$flipChecksC = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80; PreReleaseVersionLabel='servicing'; StabilizePackageVersion='false' } ` + -MainVersion @{ Major=10; Minor=0; Patch=90 } ` + -SrBranch 'release/10.0.1xx-sr8' +$flipCheckC = Get-CheckByAreaPrefix -Checks $flipChecksC -Prefix 'Versions.props servicing flip (SR8)' +Assert-Eq -Label "Flip-missing-stabilize (false): status BLOCKED" -Expected 'BLOCKED' -Actual $flipCheckC.Status +Assert-Eq -Label "Flip-missing-stabilize: details mention StabilizePackageVersion" -Expected $true ` + -Actual ([bool]($flipCheckC.Details -match 'StabilizePackageVersion')) +Assert-Eq -Label "Flip-missing-stabilize: details do NOT flag PreReleaseVersionLabel" -Expected $true ` + -Actual (-not ($flipCheckC.Details -match 'PreReleaseVersionLabel')) + +# Scenario D: SR8 with BOTH missing entirely (fresh branch cut, never flipped) — BLOCKED with both flagged +$flipChecksD = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80 } ` + -MainVersion @{ Major=10; Minor=0; Patch=90 } ` + -SrBranch 'release/10.0.1xx-sr8' +$flipCheckD = Get-CheckByAreaPrefix -Checks $flipChecksD -Prefix 'Versions.props servicing flip (SR8)' +Assert-Eq -Label "Flip-never-applied: status BLOCKED" -Expected 'BLOCKED' -Actual $flipCheckD.Status +Assert-Eq -Label "Flip-never-applied: details flag PreReleaseVersionLabel" -Expected $true ` + -Actual ([bool]($flipCheckD.Details -match 'PreReleaseVersionLabel')) +Assert-Eq -Label "Flip-never-applied: details flag StabilizePackageVersion" -Expected $true ` + -Actual ([bool]($flipCheckD.Details -match 'StabilizePackageVersion')) +Assert-Eq -Label "Flip-never-applied: details mark unset values" -Expected $true ` + -Actual ([bool]($flipCheckD.Details -match '')) +Assert-Eq -Label "Flip-never-applied: next action references the prior SR's diff" -Expected $true ` + -Actual ([bool]($flipCheckD.NextAction -match 'release/10\.0\.1xx-sr7')) + +# Scenario E: Candidate mode → flip check SKIPPED (main is supposed to be ci.main/false) +$flipChecksE = Invoke-ShipChecksWithMockedVersions ` + -SrVersion @{ Major=10; Minor=0; Patch=80; PreReleaseVersionLabel='ci.main'; StabilizePackageVersion='false' } ` + -MainVersion @{ Major=10; Minor=0; Patch=80; PreReleaseVersionLabel='ci.main'; StabilizePackageVersion='false' } ` + -SrBranch 'release/10.0.1xx-sr8' ` + -Candidate +$flipCheckE = Get-CheckByAreaPrefix -Checks $flipChecksE -Prefix 'Versions.props servicing flip' +Assert-Eq -Label "Candidate mode: servicing-flip check NOT emitted" -Expected $true ` + -Actual ($null -eq $flipCheckE) + +# ───── ci-scan freshness + rendering ───── +Write-Host "`n[Unit] Format-CiScanIssueRows + freshness" -ForegroundColor Cyan + +$nowUtc = (Get-Date).ToUniversalTime() +$ciScanIssues = @( + [PSCustomObject]@{ number = 35864; url = 'https://github.com/dotnet/maui/issues/35864'; title = 'Recurring CarouselView timeout'; + createdAt = $nowUtc.AddHours(-6).ToString('o') } + [PSCustomObject]@{ number = 35854; url = 'https://github.com/dotnet/maui/issues/35854'; title = 'Env instability CV Android'; + createdAt = $nowUtc.AddDays(-3).ToString('o') } + [PSCustomObject]@{ number = 35738; url = 'https://github.com/dotnet/maui/issues/35738'; title = 'Flaky iOS RootViewSize test'; + createdAt = $nowUtc.AddDays(-10).ToString('o') } +) +$rows = Format-CiScanIssueRows -Issues $ciScanIssues -RepoUrl 'https://github.com/dotnet/maui' +Assert-Eq -Label "Fresh issue (<24h) gets 🆕 marker" -Expected $true ` + -Actual ($rows -match '🆕\s*\[#35864\]') +Assert-Eq -Label "Older issue (>24h) does NOT get 🆕 marker" -Expected $false ` + -Actual ($rows -match '🆕\s*\[#35854\]') +Assert-Eq -Label "Age column shows '6h ago' for ~6-hour-old issue" -Expected $true ` + -Actual ($rows -match '6h ago') +Assert-Eq -Label "Age column shows 'Nd ago' for older issues" -Expected $true ` + -Actual ($rows -match '\d+d ago') +Assert-Eq -Label "Format-CiScanIssueRows returns null for empty input" -Expected $true ` + -Actual ($null -eq (Format-CiScanIssueRows -Issues @() -RepoUrl 'https://github.com/dotnet/maui')) + +# Truncation behavior: > MaxRows +$manyIssues = 1..20 | ForEach-Object { + [PSCustomObject]@{ number = 40000 + $_; url = "https://github.com/dotnet/maui/issues/$(40000+$_)"; + title = "Auto-filed $_"; createdAt = $nowUtc.AddDays(-$_).ToString('o') } +} +$rowsCapped = Format-CiScanIssueRows -Issues $manyIssues -RepoUrl 'https://github.com/dotnet/maui' -MaxRows 5 +Assert-Eq -Label "Cap respected (MaxRows=5 shows 5 issue rows)" -Expected 5 ` + -Actual ([regex]::Matches($rowsCapped, '\| \[#400').Count) +Assert-Eq -Label "Cap explanation rendered with '…and N more' note" -Expected $true ` + -Actual ($rowsCapped -match '…and 15 more') +Assert-Eq -Label "Cap explanation links to filtered issue list" -Expected $true ` + -Actual ($rowsCapped -match 'label%3Aci-scan') + +# Markdown includes ci-scan section when ciScanIssues are present +Write-Host "`n[Unit] SR markdown includes 'Recent CI Failure Scanner signals' section" -ForegroundColor Cyan + +$mdDataWithCiScan = @{} + $mdData +$mdDataWithCiScan['ciScanIssues'] = $ciScanIssues +$mdWithCiScan = Format-MarkdownReport -Data $mdDataWithCiScan -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "ci-scan section header rendered when issues present" -Expected $true ` + -Actual ($mdWithCiScan -match 'Recent CI Failure Scanner signals') +Assert-Eq -Label "ci-scan section explanatory note rendered" -Expected $true ` + -Actual ($mdWithCiScan -match 'auto-filed by the CI Failure Scanner workflow') +Assert-Eq -Label "ci-scan section links to a fresh issue" -Expected $true ` + -Actual ($mdWithCiScan -match '🆕\s*\[#35864\]') + +# Branch-filter: when filtered, blurb mentions the survey branch +Assert-Eq -Label "ci-scan blurb mentions survey branch" -Expected $true ` + -Actual ($mdWithCiScan -match 'matches `release/10\.0\.1xx-sr7`') + +# Branch-filter: when ciScanFilteredOut > 0, blurb surfaces excluded count +$mdDataWithFiltered = @{} + $mdDataWithCiScan +$mdDataWithFiltered['ciScanFilteredOut'] = 7 +$mdWithFiltered = Format-MarkdownReport -Data $mdDataWithFiltered -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "ci-scan blurb surfaces excluded-count" -Expected $true ` + -Actual ($mdWithFiltered -match '7 other-branch issue\(s\) were excluded') + +# Branch-filter: empty matched list still renders section header (with no-issues note) +$mdDataEmptyCiScan = @{} + $mdData +$mdDataEmptyCiScan['ciScanIssues'] = @() +$mdDataEmptyCiScan['ciScanFilteredOut'] = 5 +$mdEmptyCiScan = Format-MarkdownReport -Data $mdDataEmptyCiScan -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "ci-scan empty list: section still renders" -Expected $true ` + -Actual ($mdEmptyCiScan -match 'Recent CI Failure Scanner signals') +Assert-Eq -Label "ci-scan empty list: shows no-issues note for branch" -Expected $true ` + -Actual ($mdEmptyCiScan -match 'No ci-scan issues target') + +# Without ciScanIssues key → no ci-scan section +$mdNoCiScan = Format-MarkdownReport -Data $mdData -RepoUrl 'https://github.com/dotnet/maui' ` + -TrackerKey 'net10-sr7' -MaxBodyBytes 60000 +Assert-Eq -Label "No ciScanIssues key: section NOT rendered" -Expected $false ` + -Actual ($mdNoCiScan -match 'Recent CI Failure Scanner signals') + +# Get-CiScanLabelForBranch: deterministic branch → label mapping +# Replaces both Get-CiScanIssueBranch (body-marker parser, deleted) and +# Get-CiScanLabels (label-list filter, deleted). Same convention: the +# label name fully encodes the source branch (`ci-scan` = main, +# `ci-scan-net11` = net11.0, `ci-scan-net12` = net12.0, etc.). +# Preview branches are mapped to their parent net.0 so an in-flight +# preview readiness still surfaces signals from the branch the preview +# was cut from. +Write-Host "`n[Unit] Get-CiScanLabelForBranch returns canonical label per branch" -ForegroundColor Cyan + +Assert-Eq -Label "'main' → 'ci-scan'" -Expected 'ci-scan' ` + -Actual (Get-CiScanLabelForBranch -Branch 'main') +Assert-Eq -Label "'net11.0' → 'ci-scan-net11'" -Expected 'ci-scan-net11' ` + -Actual (Get-CiScanLabelForBranch -Branch 'net11.0') +Assert-Eq -Label "'net12.0' → 'ci-scan-net12' (future-proof)" -Expected 'ci-scan-net12' ` + -Actual (Get-CiScanLabelForBranch -Branch 'net12.0') +Assert-Eq -Label "preview branch → parent net.0 label" -Expected 'ci-scan-net11' ` + -Actual (Get-CiScanLabelForBranch -Branch 'release/11.0.1xx-preview6') +Assert-Eq -Label "SR branch → null (no scanner configured)" -Expected $null ` + -Actual (Get-CiScanLabelForBranch -Branch 'release/10.0.1xx-sr8') +Assert-Eq -Label "empty branch → null" -Expected $null ` + -Actual (Get-CiScanLabelForBranch -Branch '') +Assert-Eq -Label "garbage branch → null" -Expected $null ` + -Actual (Get-CiScanLabelForBranch -Branch 'feature/foo') + + +# ───── Get-CandidatePrChecks computes nextSr label from priorSrBranch ───── +# The check label uses 'SR9' (next SR) not 'SR8' (prior SR / branch passed +# to -SrBranch in candidate mode). Lock the regex that extracts the SR +# number from the prior SR branch name and increments it. +Write-Host "`n[Unit] nextSr label derivation from priorSrBranch" -ForegroundColor Cyan + +function Get-NextSrLabel { + param([string]$PriorSrBranch) + if ($PriorSrBranch -and $PriorSrBranch -match 'sr(\d+)$') { + return "SR$([int]$Matches[1] + 1)" + } + return $null +} + +Assert-Eq -Label "release/10.0.1xx-sr8 → SR9" -Expected 'SR9' ` + -Actual (Get-NextSrLabel 'release/10.0.1xx-sr8') +Assert-Eq -Label "release/9.0.2xx-sr5 → SR6" -Expected 'SR6' ` + -Actual (Get-NextSrLabel 'release/9.0.2xx-sr5') +Assert-Eq -Label "release/10.0.1xx-sr10 → SR11 (two-digit)" -Expected 'SR11' ` + -Actual (Get-NextSrLabel 'release/10.0.1xx-sr10') +Assert-Eq -Label "main → null (not an SR branch)" -Expected $null ` + -Actual (Get-NextSrLabel 'main') +Assert-Eq -Label "empty → null" -Expected $null ` + -Actual (Get-NextSrLabel '') + + +# ───── Regression test: ConvertTo-Utc handles both string + DateTime inputs ───── +# ConvertFrom-Json already returns DateTime (Kind=Utc) for ISO-8601 'Z' strings. +# A naive [DateTime]::Parse(...) re-converts to Kind=Unspecified, which then +# ToUniversalTime() misinterprets as Local, silently shifting age by the host's +# UTC offset (e.g. PDT-shifted age becomes negative). Lock the contract. +Write-Host "`n[Unit] ConvertTo-Utc handles DateTime + string input identically" -ForegroundColor Cyan + +# String input +$strUtc = ConvertTo-Utc -Value '2026-06-11T01:53:28Z' +Assert-Eq -Label "String 'Z' input → Kind=Utc" -Expected ([DateTimeKind]::Utc) -Actual $strUtc.Kind +Assert-Eq -Label "String 'Z' input → correct hour" -Expected 1 -Actual $strUtc.Hour + +# DateTime input (already Utc — what ConvertFrom-Json produces) +$dtUtc = [DateTime]::SpecifyKind('2026-06-11T01:53:28', [DateTimeKind]::Utc) +$out = ConvertTo-Utc -Value $dtUtc +Assert-Eq -Label "DateTime (Utc) input → preserved" -Expected $dtUtc.Hour -Actual $out.Hour +Assert-Eq -Label "DateTime (Utc) input → Kind stays Utc" -Expected ([DateTimeKind]::Utc) -Actual $out.Kind + +# DateTime input (Unspecified — assume UTC, don't apply local offset) +$dtUnspec = [DateTime]::SpecifyKind('2026-06-11T01:53:28', [DateTimeKind]::Unspecified) +$out2 = ConvertTo-Utc -Value $dtUnspec +Assert-Eq -Label "DateTime (Unspecified) input → assumed UTC (no offset shift)" -Expected 1 -Actual $out2.Hour + +# Null / bad input +Assert-Eq -Label "Null input returns null" -Expected $true -Actual ($null -eq (ConvertTo-Utc -Value $null)) +Assert-Eq -Label "Garbage string returns null" -Expected $true -Actual ($null -eq (ConvertTo-Utc -Value 'not-a-date')) + +# End-to-end: Format-CiScanIssueRows with a DateTime (Utc) field — must produce +# the SAME age as the equivalent string. This is the exact bug we just hit. +$twoHoursAgo = (Get-Date).ToUniversalTime().AddHours(-2) +$twoHoursAgoUtc = [DateTime]::SpecifyKind($twoHoursAgo, [DateTimeKind]::Utc) +$issueWithDtField = @( + [PSCustomObject]@{ number = 99999; url = 'https://github.com/dotnet/maui/issues/99999'; + title = 'Bug repro'; createdAt = $twoHoursAgoUtc } +) +$rowsDt = Format-CiScanIssueRows -Issues $issueWithDtField -RepoUrl 'https://github.com/dotnet/maui' +Assert-Eq -Label "DateTime createdAt (Utc): age positive (no '-Nh ago' bug)" -Expected $false ` + -Actual ($rowsDt -match '-\d+h ago') +Assert-Eq -Label "DateTime createdAt (Utc): rendered as 2h or 3h ago, not negative" -Expected $true ` + -Actual ($rowsDt -match '[23]h ago') + +# ───── Get-AzdoProp: safe AzDO API property access under StrictMode ───── +# Real-world regression: SR8 had an in-progress build (status=inProgress, no +# 'result' field) and Set-StrictMode -Version Latest threw on $latest.result. +# Tests below lock the contract that Get-AzdoProp tolerates missing properties. +Write-Host "`n[Unit] Get-AzdoProp safe AzDO property access" -ForegroundColor Cyan + +$completedBuild = [PSCustomObject]@{ id = 1; result = 'succeeded'; status = 'completed'; sourceVersion = 'sha1'; finishTime = '2026-06-11T10:00:00Z' } +$inProgressBuild = [PSCustomObject]@{ id = 2; status = 'inProgress'; sourceVersion = 'sha2' } # NO 'result', NO 'finishTime' + +Assert-Eq -Label "Get-AzdoProp returns value for present property" -Expected 'succeeded' -Actual (Get-AzdoProp $completedBuild 'result') +Assert-Eq -Label "Get-AzdoProp returns null for missing property (no throw under StrictMode)" -Expected $true -Actual ($null -eq (Get-AzdoProp $inProgressBuild 'result')) +Assert-Eq -Label "Get-AzdoProp returns null for missing 'finishTime'" -Expected $true -Actual ($null -eq (Get-AzdoProp $inProgressBuild 'finishTime')) +Assert-Eq -Label "Get-AzdoProp returns null when input is null" -Expected $true -Actual ($null -eq (Get-AzdoProp $null 'anything')) +Assert-Eq -Label "Get-AzdoProp returns status field on in-progress build" -Expected 'inProgress' -Actual (Get-AzdoProp $inProgressBuild 'status') +# Nested access (used for $latest._links.web.href) — multi-level missing must also be safe +$noLinksBuild = [PSCustomObject]@{ id = 3; status = 'inProgress' } +$innerLinks = Get-AzdoProp $noLinksBuild '_links' +Assert-Eq -Label "Get-AzdoProp nested: null base → null result" -Expected $true -Actual ($null -eq $innerLinks) +# Hashtable input (the API response is sometimes constructed as a hashtable in tests) +$hashLike = [PSCustomObject]@{ value = @('a','b') } +$hashVal = Get-AzdoProp $hashLike 'value' +Assert-Eq -Label "Get-AzdoProp returns array value when 'value' present" -Expected '2' -Actual "$($hashVal.Count)" + +# ────────────────────────────────────────────────────────────────────────── +# Get-MaestroOperationalChecks — BAR / darc default-channel & build lookups +# ────────────────────────────────────────────────────────────────────────── +Write-Host "`n[Unit] Get-MaestroOperationalChecks — BAR default-channel + per-commit build" -ForegroundColor Cyan + +function Invoke-MaestroChecksWithMocks { + <# + Test harness for Get-MaestroOperationalChecks. + Mocks Test-DarcAvailable + Invoke-DarcJson so we exercise the real check + logic without needing darc, BAR auth, or network access. + + Parameters: + -DarcAvailable $true|$false — controls Test-DarcAvailable response + -DefaultChannelsAuthFail switch — when set, mock returns Success=$false + -DefaultChannelsResponse array of mock mappings (used when not auth-failing). + Empty array = darc returned no mappings. + -BuildAuthFail switch — when set, mock returns Success=$false + -BuildResponse array of mock builds; empty = no builds for HEAD + -SrBranch / -SrHeadSha / -Mode / -SkipChecks — passed through to ctx + #> + param( + [bool]$DarcAvailable = $true, + [switch]$DefaultChannelsAuthFail, + $DefaultChannelsResponse = @(), + [switch]$BuildAuthFail, + $BuildResponse = @(), + [string]$SrBranch = 'release/10.0.1xx-sr8', + [string]$SrHeadSha = 'a11840bfdeadbeefcafebabe1234567890abcdef', + [string]$Mode = 'in-flight', + [switch]$SkipChecks + ) + $script:_mockDarcAvail = $DarcAvailable + $script:_mockDCAuthFail = [bool]$DefaultChannelsAuthFail + $script:_mockDC = @($DefaultChannelsResponse) + $script:_mockBuildAuthFail = [bool]$BuildAuthFail + $script:_mockBuilds = @($BuildResponse) + + function global:Test-DarcAvailable { return $script:_mockDarcAvail } + function global:Invoke-DarcJson { + param([string[]]$DarcArgs) + if ($DarcArgs[0] -eq 'get-default-channels') { + if ($script:_mockDCAuthFail) { + return [PSCustomObject]@{ Success = $false; Data = @() } + } + return [PSCustomObject]@{ Success = $true; Data = @($script:_mockDC) } + } + if ($DarcArgs[0] -eq 'get-build') { + if ($script:_mockBuildAuthFail) { + return [PSCustomObject]@{ Success = $false; Data = @() } + } + return [PSCustomObject]@{ Success = $true; Data = @($script:_mockBuilds) } + } + return [PSCustomObject]@{ Success = $false; Data = @() } + } + + try { + $ctx = @{ + repo = 'dotnet/maui' + srBranch = $SrBranch + srRef = "origin/$SrBranch" + srHeadSha = $SrHeadSha + mode = $Mode + mainBranch = 'main' + } + return Get-MaestroOperationalChecks -Ctx $ctx -SkipChecks:$SkipChecks + } finally { + Remove-Item function:global:Test-DarcAvailable -ErrorAction SilentlyContinue + Remove-Item function:global:Invoke-DarcJson -ErrorAction SilentlyContinue + } +} + +# Helper: find a check whose Area STARTS WITH a prefix (the SR HEAD short SHA +# varies per test fixture, so we can't match the full Area string). +function Get-MaestroCheckByPrefix { + param($Checks, [string]$Prefix) + @($Checks | Where-Object { $_.Area.StartsWith($Prefix) }) | Select-Object -First 1 +} + +# Fixture: realistic get-default-channels response (subset, includes SR7 + SR8 +# absent, mirroring the real-world SR8-not-wired state we discovered). +$mockChannelsWithSr7 = @( + [PSCustomObject]@{ id = 6945; repository = 'https://github.com/dotnet/maui'; branch = 'release/10.0.1xx-sr7'; enabled = $true; channel = [PSCustomObject]@{ id = 5174; name = '.NET 10.0.1xx SDK'; classification = 'product' } } + [PSCustomObject]@{ id = 6604; repository = 'https://github.com/dotnet/maui'; branch = 'main'; enabled = $true; channel = [PSCustomObject]@{ id = 5174; name = '.NET 10.0.1xx SDK'; classification = 'product' } } +) +$mockChannelsWithSr8 = $mockChannelsWithSr7 + @( + [PSCustomObject]@{ id = 7100; repository = 'https://github.com/dotnet/maui'; branch = 'release/10.0.1xx-sr8'; enabled = $true; channel = [PSCustomObject]@{ id = 5174; name = '.NET 10.0.1xx SDK'; classification = 'product' } } +) +$mockChannelsSr8Disabled = $mockChannelsWithSr7 + @( + [PSCustomObject]@{ id = 7100; repository = 'https://github.com/dotnet/maui'; branch = 'release/10.0.1xx-sr8'; enabled = $false; channel = [PSCustomObject]@{ id = 5174; name = '.NET 10.0.1xx SDK'; classification = 'product' } } +) +$mockBuildForHead = @( + [PSCustomObject]@{ + id = 318278; repository = 'https://github.com/dotnet/maui'; branch = 'release/10.0.1xx-sr8' + commit = 'a11840bfdeadbeefcafebabe1234567890abcdef'; buildNumber = '20260610.5' + dateProduced = '6/11/2026 1:53 AM'; buildLink = 'https://dev.azure.com/dnceng/internal/_build/results?buildId=2997620' + azdoBuildId = 2997620; released = $false; channels = @('.NET 10.0.1xx SDK') + } +) + +# ── Scenario 1: darc unavailable (CI) — both checks UNKNOWN with hints ── +$s1 = Invoke-MaestroChecksWithMocks -DarcAvailable $false +Assert-Eq -Label "darc-unavailable: emits exactly 2 checks" -Expected 2 -Actual @($s1).Count +$s1Map = Get-MaestroCheckByPrefix -Checks $s1 -Prefix 'BAR default-channel' +Assert-Eq -Label "darc-unavailable: mapping check is UNKNOWN" -Expected 'UNKNOWN' -Actual $s1Map.Status +Assert-Eq -Label "darc-unavailable: mapping NextAction mentions add-default-channel" -Expected $true ` + -Actual ($s1Map.NextAction -match 'add-default-channel') +$s1Build = Get-MaestroCheckByPrefix -Checks $s1 -Prefix 'BAR build for SR HEAD' +Assert-Eq -Label "darc-unavailable: build check is UNKNOWN" -Expected 'UNKNOWN' -Actual $s1Build.Status + +# ── Scenario 2: SR branch present in BAR mappings + build for HEAD → 2x READY ── +$s2 = Invoke-MaestroChecksWithMocks -DefaultChannelsResponse $mockChannelsWithSr8 -BuildResponse $mockBuildForHead +$s2Map = Get-MaestroCheckByPrefix -Checks $s2 -Prefix 'BAR default-channel' +Assert-Eq -Label "sr-mapped + build-present: mapping is READY" -Expected 'READY' -Actual $s2Map.Status +Assert-Eq -Label "sr-mapped + build-present: mapping details name the channel" -Expected $true ` + -Actual ($s2Map.Details -match '\.NET 10\.0\.1xx SDK') +$s2Build = Get-MaestroCheckByPrefix -Checks $s2 -Prefix 'BAR build for SR HEAD' +Assert-Eq -Label "sr-mapped + build-present: build check is READY" -Expected 'READY' -Actual $s2Build.Status +Assert-Eq -Label "sr-mapped + build-present: build details show build number" -Expected $true ` + -Actual ($s2Build.Details -match '20260610\.5') + +# ── Scenario 3: SR branch MISSING from BAR (the SR8 real-world bug) → BLOCKED ── +$s3 = Invoke-MaestroChecksWithMocks -DefaultChannelsResponse $mockChannelsWithSr7 -BuildResponse @() +$s3Map = Get-MaestroCheckByPrefix -Checks $s3 -Prefix 'BAR default-channel' +Assert-Eq -Label "sr-not-mapped: mapping is BLOCKED" -Expected 'BLOCKED' -Actual $s3Map.Status +Assert-Eq -Label "sr-not-mapped: mapping details mention 'NO default-channel mapping'" -Expected $true ` + -Actual ($s3Map.Details -match 'NO default-channel mapping') +Assert-Eq -Label "sr-not-mapped: mapping NextAction has the exact darc add-default-channel command" -Expected $true ` + -Actual ($s3Map.NextAction -match 'darc add-default-channel.*--channel ".NET 10\.0\.1xx SDK"') + +# ── Scenario 4: SR mapping exists but disabled → still BLOCKED (treated as missing) ── +$s4 = Invoke-MaestroChecksWithMocks -DefaultChannelsResponse $mockChannelsSr8Disabled +$s4Map = Get-MaestroCheckByPrefix -Checks $s4 -Prefix 'BAR default-channel' +Assert-Eq -Label "sr-mapped-but-disabled: still BLOCKED" -Expected 'BLOCKED' -Actual $s4Map.Status + +# ── Scenario 5: get-default-channels returns null (auth failure) → UNKNOWN ── +$s5 = Invoke-MaestroChecksWithMocks -DefaultChannelsAuthFail +$s5Map = Get-MaestroCheckByPrefix -Checks $s5 -Prefix 'BAR default-channel' +Assert-Eq -Label "darc-call-failed: mapping is UNKNOWN with auth-issue hint" -Expected 'UNKNOWN' -Actual $s5Map.Status +Assert-Eq -Label "darc-call-failed: mapping details mention auth/network" -Expected $true ` + -Actual ($s5Map.Details -match 'auth') + +# ── Scenario 6: mapping OK but no build for HEAD → WATCH (CI in flight) ── +$s6 = Invoke-MaestroChecksWithMocks -DefaultChannelsResponse $mockChannelsWithSr8 -BuildResponse @() +$s6Build = Get-MaestroCheckByPrefix -Checks $s6 -Prefix 'BAR build for SR HEAD' +Assert-Eq -Label "no-build-for-head: build check is WATCH (not BLOCKED — transient)" -Expected 'WATCH' -Actual $s6Build.Status + +# ── Scenario 7: candidate mode → no checks emitted (SR doesn't exist yet) ── +$s7 = Invoke-MaestroChecksWithMocks -Mode 'candidate' -DefaultChannelsResponse $mockChannelsWithSr8 +Assert-Eq -Label "candidate-mode: emits 0 checks" -Expected 0 -Actual @($s7).Count + +# ── Scenario 8: -SkipChecks switch → no checks emitted ── +$s8 = Invoke-MaestroChecksWithMocks -SkipChecks -DefaultChannelsResponse $mockChannelsWithSr8 +Assert-Eq -Label "skip-checks: emits 0 checks" -Expected 0 -Actual @($s8).Count + +# ── Scenario 9: non-SR branch shape → no checks (don't guess channel name) ── +$s9 = Invoke-MaestroChecksWithMocks -SrBranch 'release/11.0.1xx-preview5' -DefaultChannelsResponse $mockChannelsWithSr8 +Assert-Eq -Label "preview-branch (not -srN): emits 0 checks (channel inference doesn't apply)" -Expected 0 -Actual @($s9).Count + +# ── Scenario 10: SR HEAD SHA absent from ctx → only mapping check, no build check ── +$s10 = Invoke-MaestroChecksWithMocks -SrHeadSha '' -DefaultChannelsResponse $mockChannelsWithSr8 +$s10Build = Get-MaestroCheckByPrefix -Checks $s10 -Prefix 'BAR build for SR HEAD' +Assert-Eq -Label "no-head-sha: build check is absent (only mapping emitted)" -Expected $true -Actual ($null -eq $s10Build) +Assert-Eq -Label "no-head-sha: still emits exactly 1 check (the mapping)" -Expected 1 -Actual @($s10).Count + +# ── Scenario 11: get-build returns null (auth failure) → build check UNKNOWN ── +$s11 = Invoke-MaestroChecksWithMocks -DefaultChannelsResponse $mockChannelsWithSr8 -BuildAuthFail +$s11Build = Get-MaestroCheckByPrefix -Checks $s11 -Prefix 'BAR build for SR HEAD' +Assert-Eq -Label "build-call-failed: build check is UNKNOWN" -Expected 'UNKNOWN' -Actual $s11Build.Status + +# ── Scenario 12: multiple builds for HEAD → picks highest BAR id ── +$multipleBuilds = @( + [PSCustomObject]@{ id = 318100; buildNumber = '20260609.1'; buildLink = 'https://example/1'; channels = @('.NET 10.0.1xx SDK') } + [PSCustomObject]@{ id = 318278; buildNumber = '20260610.5'; buildLink = 'https://example/2'; channels = @('.NET 10.0.1xx SDK') } + [PSCustomObject]@{ id = 318200; buildNumber = '20260609.7'; buildLink = 'https://example/3'; channels = @('.NET 10.0.1xx SDK') } +) +$s12 = Invoke-MaestroChecksWithMocks -DefaultChannelsResponse $mockChannelsWithSr8 -BuildResponse $multipleBuilds +$s12Build = Get-MaestroCheckByPrefix -Checks $s12 -Prefix 'BAR build for SR HEAD' +Assert-Eq -Label "multiple-builds: details report highest-id build (20260610.5)" -Expected $true ` + -Actual ($s12Build.Details -match '20260610\.5') + +# ── Scenario 13: SR7 branch (real, currently mapped) → READY (sanity) ── +$s13 = Invoke-MaestroChecksWithMocks -SrBranch 'release/10.0.1xx-sr7' -DefaultChannelsResponse $mockChannelsWithSr7 -BuildResponse $mockBuildForHead +$s13Map = Get-MaestroCheckByPrefix -Checks $s13 -Prefix 'BAR default-channel' +Assert-Eq -Label "sr7-already-mapped: READY" -Expected 'READY' -Actual $s13Map.Status + +# ========================================================================= +# Get-MilestoneHygieneChecks — current/next milestone existence + stale detection +# ========================================================================= +Write-Host "`n[Unit] Get-MilestoneHygieneChecks — current/next milestone existence + stale detection" -ForegroundColor Cyan + +# Mock harness — overrides Get-AllMilestones globally with a fixture, exercises +# the real Get-MilestoneHygieneChecks logic, then restores. Mirrors the +# Maestro mock pattern so any test scaffolding learning here transfers. +function Invoke-MilestoneChecksWithMocks { + param( + [switch]$ApiFail, + $MilestonesResponse = @(), + [string]$SrBranch = 'release/10.0.1xx-sr8', + [string]$PriorSrBranch, + [string]$Mode = 'in-flight', + [switch]$SkipChecks + ) + $script:_mockMsApiFail = [bool]$ApiFail + $script:_mockMsData = @($MilestonesResponse) + + function global:Get-AllMilestones { + param([string]$Repo) + if ($script:_mockMsApiFail) { + return [PSCustomObject]@{ Success = $false; Data = @() } + } + return [PSCustomObject]@{ Success = $true; Data = @($script:_mockMsData) } + } + + try { + $ctx = @{ + repo = 'dotnet/maui' + srBranch = if ($Mode -eq 'candidate') { 'main' } else { $SrBranch } + priorSrBranch = if ($Mode -eq 'candidate') { $PriorSrBranch } else { $null } + mode = $Mode + } + return Get-MilestoneHygieneChecks -Ctx $ctx -SkipChecks:$SkipChecks + } finally { + Remove-Item function:global:Get-AllMilestones -ErrorAction SilentlyContinue + } +} + +function Get-MilestoneCheckByPrefix { + param($Checks, [string]$Prefix) + if (-not $Checks) { return $null } + return @($Checks) | Where-Object { $_.Area -like "$Prefix*" } | Select-Object -First 1 +} + +# Helper to build mock milestone objects with the shape returned by gh API +function New-MockMilestone { + param( + [string]$Title, + [string]$State = 'open', + [int]$Number = 100, + [int]$OpenIssues = 0, + $DueOn = $null # ISO-8601 string; null = no due date + ) + [PSCustomObject]@{ + title = $Title + state = $State + number = $Number + open_issues = $OpenIssues + due_on = $DueOn + } +} + +# === Common fixtures === +# Past dates relative to now so the test stays valid as time passes +$daysAgo30 = (Get-Date).ToUniversalTime().AddDays(-30).ToString('o') +$daysAgo60 = (Get-Date).ToUniversalTime().AddDays(-60).ToString('o') +$daysAgo3 = (Get-Date).ToUniversalTime().AddDays(-3).ToString('o') # within grace +$daysAgo10 = (Get-Date).ToUniversalTime().AddDays(-10).ToString('o') # past grace +$daysAhead30 = (Get-Date).ToUniversalTime().AddDays(30).ToString('o') + +$mockMsAllPresent = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -OpenIssues 50 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) + (New-MockMilestone -Title 'Backlog') # no due date — always excluded + (New-MockMilestone -Title '.NET 11 Planning') # planning excluded +) + +# ── Scenario M1: Current + next milestone exist, nothing stale → 0 checks ── +$m1 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $mockMsAllPresent +Assert-Eq -Label "M1: all present, no stale → 0 checks emitted" -Expected 0 -Actual @($m1).Count + +# ── Scenario M2: SR8 milestone missing → BLOCKED current ── +$m2Data = @( + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) +) +$m2 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m2Data +$m2Curr = Get-MilestoneCheckByPrefix -Checks $m2 -Prefix 'Milestone for current cycle' +Assert-Eq -Label "M2: current missing → BLOCKED check emitted" -Expected 'BLOCKED' -Actual $m2Curr.Status +Assert-Eq -Label "M2: current missing → details name the exact missing title" -Expected $true ` + -Actual ($m2Curr.Details -match '\.NET 10 SR8') +Assert-Eq -Label "M2: current missing → action has gh api create command" -Expected $true ` + -Actual ($m2Curr.NextAction -match 'gh api repos/dotnet/maui/milestones') + +# ── Scenario M3: SR9 milestone missing → CLEANUP next ── +# Per Finding #5 follow-up: missing roll-forward milestone is housekeeping, +# not a ship blocker. The current cycle (SR8) can still ship while the +# next milestone (SR9) is created later. +$m3Data = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -OpenIssues 50 -DueOn $daysAhead30) +) +$m3 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m3Data +$m3Next = Get-MilestoneCheckByPrefix -Checks $m3 -Prefix 'Milestone for next cycle' +Assert-Eq -Label "M3: next missing → CLEANUP check emitted (not ship-blocker)" -Expected 'CLEANUP' -Actual $m3Next.Status +Assert-Eq -Label "M3: next missing → action proposes creating SR9" -Expected $true ` + -Actual ($m3Next.NextAction -match '\.NET 10 SR9') + +# ── Scenario M4: Legacy ".NET 10.0 SR8" naming also satisfies current check ── +$m4Data = @( + (New-MockMilestone -Title '.NET 10.0 SR8' -Number 117 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) +) +$m4 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m4Data +$m4Curr = Get-MilestoneCheckByPrefix -Checks $m4 -Prefix 'Milestone for current cycle' +Assert-Eq -Label "M4: legacy 'X.0 SRn' title satisfies current check" -Expected $true -Actual ($null -eq $m4Curr) + +# ── Scenario M5: Stale .NET 10 milestone past 7-day grace → BLOCKED ── +$m5Data = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR6' -Number 115 -OpenIssues 76 -DueOn $daysAgo60) + (New-MockMilestone -Title '.NET 10 SR7' -Number 116 -OpenIssues 63 -DueOn $daysAgo30) +) +$m5 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m5Data +$m5Stale = Get-MilestoneCheckByPrefix -Checks $m5 -Prefix 'Stale open milestones' +Assert-Eq -Label "M5: stale SR6+SR7 → CLEANUP check emitted (housekeeping, not blocking)" -Expected 'CLEANUP' -Actual $m5Stale.Status +Assert-Eq -Label "M5: stale count reflected in area" -Expected $true -Actual ($m5Stale.Area -match '\(2\)') +Assert-Eq -Label "M5: details mention SR6 by title" -Expected $true -Actual ($m5Stale.Details -match 'SR6') +Assert-Eq -Label "M5: details mention SR7 by title" -Expected $true -Actual ($m5Stale.Details -match 'SR7') + +# ── Scenario M6: Past-due within 7-day grace → NOT flagged ── +$m6Data = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR7' -Number 116 -OpenIssues 5 -DueOn $daysAgo3) # within grace +) +$m6 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m6Data +$m6Stale = Get-MilestoneCheckByPrefix -Checks $m6 -Prefix 'Stale open milestones' +Assert-Eq -Label "M6: within 7-day grace → no stale check" -Expected $true -Actual ($null -eq $m6Stale) + +# ── Scenario M7: Closed milestone past due → NOT flagged ── +$m7Data = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR6' -Number 115 -State 'closed' -DueOn $daysAgo60) +) +$m7 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m7Data +$m7Stale = Get-MilestoneCheckByPrefix -Checks $m7 -Prefix 'Stale open milestones' +Assert-Eq -Label "M7: closed milestone never flagged stale" -Expected $true -Actual ($null -eq $m7Stale) + +# ── Scenario M8: Backlog with no due_on → NOT flagged ── +$m8Data = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) + (New-MockMilestone -Title 'Backlog' -Number 1 -OpenIssues 3000) # no due +) +$m8 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m8Data +Assert-Eq -Label "M8: Backlog never flagged stale" -Expected 0 -Actual @($m8).Count + +# ── Scenario M9: Cross-major staleness → NOT flagged (cycle isolation) ── +# Surveying SR8 of .NET 10; stale .NET 9 SR9 should NOT flag (different major). +$m9Data = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 9 SR9' -Number 50 -OpenIssues 10 -DueOn $daysAgo60) +) +$m9 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m9Data +$m9Stale = Get-MilestoneCheckByPrefix -Checks $m9 -Prefix 'Stale open milestones' +Assert-Eq -Label "M9: .NET 9 stale milestones don't flag when surveying .NET 10 SR" -Expected $true -Actual ($null -eq $m9Stale) + +# ── Scenario M10: Cross-cycle staleness → NOT flagged (SR/preview isolation) ── +# Surveying SR8 of .NET 10; stale .NET 10.0-preview1 should NOT flag (preview vs SR). +$m10Data = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10.0-preview1' -Number 40 -OpenIssues 5 -DueOn $daysAgo60) +) +$m10 = Invoke-MilestoneChecksWithMocks -MilestonesResponse $m10Data +$m10Stale = Get-MilestoneCheckByPrefix -Checks $m10 -Prefix 'Stale open milestones' +Assert-Eq -Label "M10: preview milestones don't flag when surveying an SR cycle" -Expected $true -Actual ($null -eq $m10Stale) + +# ── Scenario M11: Preview branch surveys preview milestones ── +$m11Data = @( + (New-MockMilestone -Title '.NET 11.0-preview5' -Number 200 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 11.0-preview6' -Number 201 -DueOn $daysAhead30) +) +$m11 = Invoke-MilestoneChecksWithMocks -SrBranch 'release/11.0.1xx-preview5' -MilestonesResponse $m11Data +Assert-Eq -Label "M11: preview branch all-present → 0 checks" -Expected 0 -Actual @($m11).Count + +# ── Scenario M12: Preview branch missing next-preview → CLEANUP ── +# Per Finding #5 follow-up: missing roll-forward (preview6) milestone is +# cleanup, not a ship blocker for preview5. +$m12Data = @( + (New-MockMilestone -Title '.NET 11.0-preview5' -Number 200 -DueOn $daysAhead30) +) +$m12 = Invoke-MilestoneChecksWithMocks -SrBranch 'release/11.0.1xx-preview5' -MilestonesResponse $m12Data +$m12Next = Get-MilestoneCheckByPrefix -Checks $m12 -Prefix 'Milestone for next cycle' +Assert-Eq -Label "M12: preview6 missing → CLEANUP next-cycle check (not ship-blocker)" -Expected 'CLEANUP' -Actual $m12Next.Status +Assert-Eq -Label "M12: details name preview6 by exact title" -Expected $true ` + -Actual ($m12Next.Area -match '\.NET 11\.0-preview6') + +# ── Scenario M13: Candidate mode for SR (priorSr = SR7 → candidate is SR8) ── +$m13Data = @( + (New-MockMilestone -Title '.NET 10 SR8' -Number 117 -DueOn $daysAhead30) + (New-MockMilestone -Title '.NET 10 SR9' -Number 118 -DueOn $daysAhead30) +) +$m13 = Invoke-MilestoneChecksWithMocks -Mode 'candidate' -PriorSrBranch 'release/10.0.1xx-sr7' -MilestonesResponse $m13Data +Assert-Eq -Label "M13: candidate-mode SR (prior=SR7) accepts SR8/SR9 → 0 checks" -Expected 0 -Actual @($m13).Count + +# ── Scenario M14: -SkipChecks → 0 checks even with missing milestones ── +$m14 = Invoke-MilestoneChecksWithMocks -SkipChecks -MilestonesResponse @() +Assert-Eq -Label "M14: SkipChecks emits 0 checks" -Expected 0 -Actual @($m14).Count + +# ── Scenario M15: Non-SR / non-preview branch → 0 checks (silent skip) ── +$m15 = Invoke-MilestoneChecksWithMocks -SrBranch 'release/10.0.1xx-rc1' -MilestonesResponse @() +Assert-Eq -Label "M15: RC branch shape → 0 checks (can't infer milestone name)" -Expected 0 -Actual @($m15).Count + +# ── Scenario M16: API failure → UNKNOWN check (gh auth gap) ── +$m16 = Invoke-MilestoneChecksWithMocks -ApiFail +$m16Unk = Get-MilestoneCheckByPrefix -Checks $m16 -Prefix 'Milestone hygiene' +Assert-Eq -Label "M16: API fail → UNKNOWN status" -Expected 'UNKNOWN' -Actual $m16Unk.Status +Assert-Eq -Label "M16: API fail action mentions gh auth status" -Expected $true ` + -Actual ($m16Unk.NextAction -match 'gh auth status') + +# ───── Get-ExpectedShipDate: deterministic 2nd-Tuesday math + hotfix cadence ───── +# .NET releases ship on the 2nd Tuesday of every month for x0 patches (80, 90, 100…) +# and previews. Hotfix patches (81, 82…) ship ASAP — no cadence. +Write-Host "`n[Unit] Get-ExpectedShipDate (2nd Tuesday + hotfix)" -ForegroundColor Cyan + +# 2nd Tuesday calendar for sanity (verified independently): +# June 2026: 2nd Tue = June 9 +# July 2026: 2nd Tue = July 14 +# Aug 2026: 2nd Tue = Aug 11 +# May 2026: 2nd Tue = May 12 +# Feb 2026: 2nd Tue = Feb 10 (no leap-week issue) + +# Scenario T1: x0 patch + BEFORE this month's 2nd Tuesday → use this month +$t1 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-01') -PatchVersion 80 +Assert-Eq -Label "T1: 06-01 + patch=80 → cadence second-tuesday" -Expected 'second-tuesday' -Actual $t1.Cadence +Assert-Eq -Label "T1: 06-01 → June 9 2026" -Expected '2026-06-09' -Actual $t1.Date.ToString('yyyy-MM-dd') +Assert-Eq -Label "T1: days from 06-01 = 8" -Expected 8 -Actual $t1.DaysFromNow + +# Scenario T2: x0 patch + AFTER this month's 2nd Tuesday → roll to next month +$t2 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 80 +Assert-Eq -Label "T2: 06-11 (past June 9) → July 14 2026" -Expected '2026-07-14' -Actual $t2.Date.ToString('yyyy-MM-dd') +Assert-Eq -Label "T2: days from 06-11 = 33" -Expected 33 -Actual $t2.DaysFromNow + +# Scenario T3: today IS the 2nd Tuesday → return today (DaysFromNow = 0) +$t3 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-09') -PatchVersion 80 +Assert-Eq -Label "T3: 06-09 IS June's 2nd Tue → 06-09 returned" -Expected '2026-06-09' -Actual $t3.Date.ToString('yyyy-MM-dd') +Assert-Eq -Label "T3: days from shipping day = 0" -Expected 0 -Actual $t3.DaysFromNow + +# Scenario T4: month starts on a Tuesday → first Tue is day 1, second Tue is day 8 +# Sept 2026 starts on a Tuesday (Sept 1 = Tue). +$t4 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-09-01') -PatchVersion 90 +Assert-Eq -Label "T4: 09-01 (month starts on Tue) → Sept 8" -Expected '2026-09-08' -Actual $t4.Date.ToString('yyyy-MM-dd') + +# Scenario T5: month rollover crossing year boundary — December past 2nd Tue → January +$t5 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-12-15') -PatchVersion 100 +Assert-Eq -Label "T5: 12-15 (past Dec 8) → Jan 12 2027" -Expected '2027-01-12' -Actual $t5.Date.ToString('yyyy-MM-dd') + +# Scenario T6: formatted string includes day-of-week + month name +$t6 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 80 +Assert-Eq -Label "T6: FormattedLong contains 'Tuesday'" -Expected $true -Actual ([bool]($t6.FormattedLong -match '^Tuesday')) +Assert-Eq -Label "T6: FormattedLong contains 'July'" -Expected $true -Actual ([bool]($t6.FormattedLong -match 'July')) +Assert-Eq -Label "T6: FormattedLong contains '14'" -Expected $true -Actual ([bool]($t6.FormattedLong -match '\b14\b')) +Assert-Eq -Label "T6: FormattedLong contains '2026'" -Expected $true -Actual ([bool]($t6.FormattedLong -match '2026')) + +# Scenario T7: month starts on Wednesday (e.g. Jul 2026: Jul 1 = Wed) — first Tue = Jul 7, second Tue = Jul 14 +$t7 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-07-01') -PatchVersion 80 +Assert-Eq -Label "T7: 07-01 (month starts on Wed) → Jul 14" -Expected '2026-07-14' -Actual $t7.Date.ToString('yyyy-MM-dd') + +# Scenario T8: time-of-day portion shouldn't affect the result +$t8 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-09T23:59:00Z') -PatchVersion 80 +Assert-Eq -Label "T8: time-of-day stripped → 06-09 still recognized as shipping day" -Expected 0 -Actual $t8.DaysFromNow + +# Scenario T9: patch=$null (caller doesn't know) → defaults to 2nd-Tuesday cadence +$t9 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') +Assert-Eq -Label "T9: patch=$null → cadence second-tuesday (back-compat)" -Expected 'second-tuesday' -Actual $t9.Cadence +Assert-Eq -Label "T9: patch=$null → still produces a date" -Expected '2026-07-14' -Actual $t9.Date.ToString('yyyy-MM-dd') + +# Scenario T10: hotfix patch (81) → ASAP, NO cadence +$t10 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 81 +Assert-Eq -Label "T10: patch=81 → cadence asap-hotfix" -Expected 'asap-hotfix' -Actual $t10.Cadence +Assert-Eq -Label "T10: patch=81 → Date is null" -Expected $true -Actual ($null -eq $t10.Date) +Assert-Eq -Label "T10: patch=81 → DaysFromNow is null" -Expected $true -Actual ($null -eq $t10.DaysFromNow) +Assert-Eq -Label "T10: patch=81 → FormattedLong mentions ASAP" -Expected $true -Actual ([bool]($t10.FormattedLong -match 'ASAP')) +Assert-Eq -Label "T10: patch=81 → Note mentions hotfix" -Expected $true -Actual ([bool]($t10.Note -match 'hotfix')) +Assert-Eq -Label "T10: patch=81 → Note quotes the patch" -Expected $true -Actual ([bool]($t10.Note -match '\b81\b')) + +# Scenario T11: hotfix mid-range (85) → still ASAP +$t11 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 85 +Assert-Eq -Label "T11: patch=85 → cadence asap-hotfix" -Expected 'asap-hotfix' -Actual $t11.Cadence + +# Scenario T12: another decade boundary — patch=91 (SR9 hotfix) → ASAP +$t12 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 91 +Assert-Eq -Label "T12: patch=91 → cadence asap-hotfix" -Expected 'asap-hotfix' -Actual $t12.Cadence + +# Scenario T13: preview/major-zero patch (0) → 2nd-Tuesday (0 % 10 == 0) +$t13 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 0 +Assert-Eq -Label "T13: patch=0 (preview) → cadence second-tuesday" -Expected 'second-tuesday' -Actual $t13.Cadence + +# Scenario T14: patch=100 (triple digit, % 10 == 0) → 2nd-Tuesday +$t14 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 100 +Assert-Eq -Label "T14: patch=100 → cadence second-tuesday" -Expected 'second-tuesday' -Actual $t14.Cadence + +# ───── Get-ExpectedShipDate with MainBumpDate anchoring ───── +# The real bug: without an anchor, the fallback rolls forward when the SR's +# month passes — so SR8 (patch=80, expected June 9) wrongly slid into July 14 +# (SR9's window) once June 9 passed. MainBumpDate fixes that. + +# T15: SR8 — main bumped 70→80 on 2026-05-13 → SR8 ships 2nd Tue of June (06-09). +# Today = 2026-06-01 (BEFORE June 9) → date = June 9, days = 8, not missed. +$t15 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-01') -PatchVersion 80 -MainBumpDate ([DateTime]'2026-05-13') +Assert-Eq -Label "T15: bump 05-13 + today 06-01 → 2026-06-09" -Expected '2026-06-09' -Actual $t15.Date.ToString('yyyy-MM-dd') +Assert-Eq -Label "T15: days from 06-01 = 8" -Expected 8 -Actual $t15.DaysFromNow +Assert-Eq -Label "T15: not missed" -Expected $false -Actual $t15.MissedWindow +Assert-Eq -Label "T15: anchorSource = main-bump" -Expected 'main-bump' -Actual $t15.AnchorSource +Assert-Eq -Label "T15: cadence = second-tuesday" -Expected 'second-tuesday' -Actual $t15.Cadence + +# T16: SR8 — main bumped 70→80 on 2026-05-13. Today = 2026-06-11 (AFTER June 9). +# WITHOUT anchor, function would say July 14 (SR9 territory). WITH anchor, +# we get the correct June 9 date but flagged as missed. +$t16 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 80 -MainBumpDate ([DateTime]'2026-05-13') +Assert-Eq -Label "T16: bump 05-13 + today 06-11 → 2026-06-09 (still anchored)" -Expected '2026-06-09' -Actual $t16.Date.ToString('yyyy-MM-dd') +Assert-Eq -Label "T16: missedWindow = true" -Expected $true -Actual $t16.MissedWindow +Assert-Eq -Label "T16: days from 06-11 = -2" -Expected -2 -Actual $t16.DaysFromNow +Assert-Eq -Label "T16: cadence = second-tuesday-missed" -Expected 'second-tuesday-missed' -Actual $t16.Cadence + +# T17: SR9 — main bumped 80→90 on 2026-06-15 → SR9 ships 2nd Tue of July (07-14). +# Today = 2026-06-11 → before bump, so this is more theoretical, but if you call +# with bump date 06-15 you get July 14. +$t17 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 90 -MainBumpDate ([DateTime]'2026-06-15') +Assert-Eq -Label "T17: bump 06-15 → 2026-07-14" -Expected '2026-07-14' -Actual $t17.Date.ToString('yyyy-MM-dd') +Assert-Eq -Label "T17: anchorSource = main-bump" -Expected 'main-bump' -Actual $t17.AnchorSource + +# T18: anchor wins over fallback even when both would give same answer. +$t18 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-01') -PatchVersion 80 +Assert-Eq -Label "T18: no MainBumpDate → fallback (current-month anchor)" -Expected 'fallback-current-month' -Actual $t18.AnchorSource + +# T19: hotfix patch ignores MainBumpDate (cadence wins). +$t19 = Get-ExpectedShipDate -ReferenceDate ([DateTime]'2026-06-11') -PatchVersion 81 -MainBumpDate ([DateTime]'2026-05-13') +Assert-Eq -Label "T19: patch=81 + MainBumpDate → asap-hotfix" -Expected 'asap-hotfix' -Actual $t19.Cadence +Assert-Eq -Label "T19: missedWindow = false for hotfix" -Expected $false -Actual $t19.MissedWindow + +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 }) diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml new file mode 100644 index 000000000000..518b83206a25 --- /dev/null +++ b/.github/workflows/release-readiness.yml @@ -0,0 +1,548 @@ +name: Release Readiness + +# Unified release-readiness workflow. Each day: +# 1. detect-trackers: invoke Find-ReleaseReadinessTrackers -AllActiveMajors to enumerate every +# active in-flight/candidate branch across all active majors (SR + preview). +# 2. matrix expansion: emit one matrix job per tracker (≤ a handful per day). +# 3. per-tracker readiness: dispatch the right report script based on branchType +# ('sr' -> Get-ReleaseReadiness.ps1; 'preview' -> Get-PreviewReadiness.ps1) +# and write a daily "[Release Readiness]" issue idempotently: +# - reuse open tracker issue by canonicalKey marker if it already exists +# - otherwise close any older daily issues for the same tracker and create a new one +# - skip new-issue creation when the tracker has zero recent commits AND no open tracker issue +# 4. validate: PR-trigger path runs the same scripts but only validates output — no issue creation. +# +# Permissions: the cron/dispatch path requires `issues: write`; PR validation runs with the minimum. + +on: + schedule: + - cron: "30 8 * * 1-5" # Weekdays at 08:30 UTC + workflow_dispatch: + inputs: + branch: + description: "Restrict to a single branch (e.g. release/10.0.1xx-sr8 or release/11.0.1xx-preview6). Empty = all detected trackers." + required: false + default: "" + create_issue: + description: "Create/update the daily public Release Readiness issue(s)" + type: boolean + required: false + default: true + pull_request: + types: [opened, synchronize] + paths: + - '.github/workflows/release-readiness.yml' + - '.github/skills/release-readiness/**' + - '.github/scripts/shared/MauiReleaseVersioning.psm1' + +permissions: + contents: read + +concurrency: + group: release-readiness-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.branch || 'all' }} + cancel-in-progress: true + +jobs: + # ──────────────────────────────────────────────────────────────────── + # Job 1 — detect trackers and emit a JSON matrix + # ──────────────────────────────────────────────────────────────────── + detect-trackers: + name: Detect release trackers + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + outputs: + matrix: ${{ steps.detect.outputs.matrix }} + has-trackers: ${{ steps.detect.outputs.has-trackers }} + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Find-Trackers needs full history + tags for tag-existence detection + + - name: Detect release-readiness trackers + id: detect + env: + GH_TOKEN: ${{ github.token }} + BRANCH_FILTER: ${{ inputs.branch || '' }} + shell: bash + run: | + set -euo pipefail + pwsh -NoProfile -File .github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1 \ + -AllActiveMajors \ + -OutputJson trackers.json + + if [ ! -s trackers.json ]; then + echo "::error::Find-ReleaseReadinessTrackers produced no JSON" + exit 1 + fi + + # Flatten majors[].trackers[] into a single matrix array. If BRANCH_FILTER + # is set, narrow to trackers whose branchName OR surveyRef matches. + jq --arg filter "$BRANCH_FILTER" ' + [ .majors[].trackers[] + | select($filter == "" or .branchName == $filter or .surveyRef == $filter) + | { + canonicalKey: .canonicalKey, + branchType: .branchType, + branchName: .branchName, + branchExists: .branchExists, + surveyRef: .surveyRef, + mode: .mode, + majorVersion: .majorVersion, + issueTitle: .issueTitle, + milestoneName: .milestoneName, + recentCommitCount: .recentCommitCount, + hasRecentActivity: .hasRecentActivity, + # SR-only fields (null for preview trackers) + priorSrBranch: (.priorSrBranch // ""), + regressionLabels: (.regressionLabels // []), + # Preview-only fields (null for SR trackers) + previewNumber: (.previewNumber // null) + } + ] + ' trackers.json > matrix.json + + MATRIX_LEN=$(jq 'length' matrix.json) + echo "Detected $MATRIX_LEN tracker(s)" + jq -c '.' matrix.json + + # Encode matrix for GitHub Actions matrix expansion. + MATRIX_JSON=$(jq -c '{include: .}' matrix.json) + echo "matrix=$MATRIX_JSON" >> "$GITHUB_OUTPUT" + if [ "$MATRIX_LEN" -gt 0 ]; then + echo "has-trackers=true" >> "$GITHUB_OUTPUT" + else + echo "has-trackers=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload trackers.json + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-readiness-trackers + path: | + trackers.json + matrix.json + retention-days: 30 + + # ──────────────────────────────────────────────────────────────────── + # Job 2 — per-tracker readiness report (one matrix job per tracker) + # ──────────────────────────────────────────────────────────────────── + per-tracker-report: + name: ${{ matrix.canonicalKey }} (${{ matrix.branchType }}) + needs: detect-trackers + if: needs.detect-trackers.outputs.has-trackers == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.detect-trackers.outputs.matrix) }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate readiness report + id: report + env: + GH_TOKEN: ${{ github.token }} + BRANCH_TYPE: ${{ matrix.branchType }} + BRANCH_NAME: ${{ matrix.branchName }} + BRANCH_EXISTS: ${{ matrix.branchExists }} + SURVEY_REF: ${{ matrix.surveyRef }} + MODE: ${{ matrix.mode }} + TRACKER_KEY: ${{ matrix.canonicalKey }} + PRIOR_SR: ${{ matrix.priorSrBranch }} + REG_LABELS: ${{ join(matrix.regressionLabels, ',') }} + shell: bash + run: | + set -euo pipefail + mkdir -p readiness-out + + if [ "$BRANCH_TYPE" = "sr" ]; then + # SR readiness: + # in-flight → -SrBranch (no Candidate flag) + # candidate → -SrBranch -Candidate + # Find-ReleaseReadinessTrackers's New-RegressionLabelList always + # returns at least one label for every SR, so REG_LABELS is never + # empty here — wire the labels through directly without the + # legacy -InferRegressionLabels fallback. + if [ -z "$REG_LABELS" ]; then + echo "::error::SR tracker $TRACKER_KEY missing regressionLabels (Find-Trackers should always emit ≥1)" + exit 1 + fi + REG_LABEL_ARG=(-RegressionLabels "$REG_LABELS") + + CANDIDATE_ARG=() + if [ "$MODE" = "candidate" ]; then + if [ -z "$PRIOR_SR" ]; then + echo "::error::SR candidate tracker $TRACKER_KEY missing priorSrBranch" + exit 1 + fi + SR_ARG="$PRIOR_SR" + CANDIDATE_ARG=(-Candidate) + else + SR_ARG="$BRANCH_NAME" + fi + + pwsh -NoProfile -File .github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 \ + -SrBranch "$SR_ARG" \ + "${CANDIDATE_ARG[@]}" \ + "${REG_LABEL_ARG[@]}" \ + -TrackerKey "$TRACKER_KEY" \ + -OutputDir readiness-out + BODY_FILE="readiness-out/release-readiness.md" + + elif [ "$BRANCH_TYPE" = "preview" ]; then + # Preview readiness — Get-PreviewReadiness.ps1 always takes the + # canonical preview branch name (whether it exists yet or not); + # candidate mode flips -SurveyRef to net.0. + pwsh -NoProfile -File .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ + -Branch "$BRANCH_NAME" \ + -Mode "$MODE" \ + -SurveyRef "$SURVEY_REF" \ + -TrackerKey "$TRACKER_KEY" \ + -OutputDir readiness-out \ + -OutputFormat markdown + BODY_FILE="readiness-out/preview-readiness.md" + else + echo "::error::Unknown branchType '$BRANCH_TYPE'" + exit 1 + fi + + if [ ! -s "$BODY_FILE" ]; then + echo "::error::Readiness body file is empty: $BODY_FILE" + exit 1 + fi + + echo "body-file=$BODY_FILE" >> "$GITHUB_OUTPUT" + { + echo "## ${TRACKER_KEY} (${BRANCH_TYPE})" + echo "" + cat "$BODY_FILE" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload readiness artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: readiness-${{ matrix.canonicalKey }} + path: readiness-out/ + retention-days: 30 + + - name: Update or create tracker issue + if: github.event_name == 'schedule' || inputs.create_issue + env: + GH_TOKEN: ${{ github.token }} + TRACKER_KEY: ${{ matrix.canonicalKey }} + ISSUE_TITLE: ${{ matrix.issueTitle }} + MILESTONE_NAME: ${{ matrix.milestoneName }} + BODY_FILE: ${{ steps.report.outputs.body-file }} + RECENT_COMMIT_COUNT: ${{ matrix.recentCommitCount }} + shell: bash + run: | + set -euo pipefail + + # Find any open issue whose body carries the canonical marker for this tracker. + # This is the idempotent join key — Get-ReleaseReadiness / Get-PreviewReadiness + # both embed ``. + MARKER="" + EXISTING=$(gh issue list \ + --repo "${{ github.repository }}" \ + --state open \ + --search "in:body \"${MARKER}\"" \ + --json number,title,createdAt \ + --limit 50 \ + --jq '. // [] | sort_by(.createdAt) | .[].number') + + # Activity gate: when there is no recent activity AND no open tracker issue, + # skip new-issue creation. (If an existing issue is open, we still refresh it.) + if [ "$RECENT_COMMIT_COUNT" -eq 0 ] && [ -z "$EXISTING" ]; then + echo "Skipping ${TRACKER_KEY}: no recent commits and no open tracker issue." + exit 0 + fi + + if [ -n "$EXISTING" ]; then + # Reuse the OLDEST open tracker issue (first in chronological order). + # Close any duplicates created by past misfires before refreshing the canonical one. + CANONICAL=$(echo "$EXISTING" | head -n 1) + DUPLICATES=$(echo "$EXISTING" | tail -n +2 || true) + + for dup in $DUPLICATES; do + echo "Closing duplicate tracker issue #$dup" + gh issue close "$dup" \ + --repo "${{ github.repository }}" \ + --reason "not planned" \ + --comment "Closing as duplicate of #${CANONICAL} — there should be exactly one open tracker per release branch." || true + done + + echo "Refreshing tracker issue #${CANONICAL} for ${TRACKER_KEY}" + + # Preserve the human-editable "Release Captain Notes" block and avoid + # churning the issue when nothing material changed. Both engines emit + # markers; the SR engine + # additionally embeds . + CUR_BODY_FILE="$(mktemp)" + # Capture the live issue body. A transient fetch failure must NOT lead + # to an overwrite: an empty CUR_BODY_FILE would skip the notes splice + # AND zero out OLD_HASH, falling through to `gh issue edit` and wiping + # the human-authored Release Captain Notes. Guard the exit status and + # skip the whole refresh instead (a missing refresh self-heals next run; + # lost notes do not). + CUR_FETCH_OK=1 + gh issue view "$CANONICAL" \ + --repo "${{ github.repository }}" \ + --json body --jq '.body // ""' > "$CUR_BODY_FILE" || CUR_FETCH_OK=0 + + if [ "$CUR_FETCH_OK" -ne 1 ]; then + echo "::warning::Could not read live body of issue #${CANONICAL}; skipping refresh to protect Release Captain Notes." + else + # Detect the human-notes block using the SAME anchored full-line + # markers the awk splice relies on. A substring (unanchored) guard + # desyncs from the awk and silently wipes the Release Captain Notes: + # * a note that merely MENTIONS the end token makes the count 2, the + # -eq 1 guard fails, the splice is skipped, and the edit overwrites + # the notes; and + # * a marker line carrying trailing text passes a substring guard but + # the anchored awk matches nothing, splicing in an EMPTY block. + # The anchors tolerate the CRLF bodies GitHub returns (\r is ASCII + # whitespace in every locale). LC_ALL=C is MANDATORY on every grep and + # awk here: GNU grep in the runner's UTF-8 locale treats Unicode spaces + # (e.g. U+00A0 NO-BREAK SPACE, easily pasted from a web editor) as + # [[:space:]], but mawk (the runner default) does not — so a UTF-8 + # grep guard could PASS while the awk extracts nothing, splicing an + # EMPTY block over real notes. Forcing C locale makes grep and awk + # agree on ASCII-only [[:space:]], so a weird space fails the guard and + # freezes the issue (safe) instead of destroying the notes. + NOTES_BEGIN_RE='^[[:space:]]*[[:space:]]*$' + NOTES_END_RE='^[[:space:]]*[[:space:]]*$' + CUR_HAS_CLEAN_NOTES=0 + if [ "$(LC_ALL=C grep -cE "$NOTES_BEGIN_RE" "$CUR_BODY_FILE")" -eq 1 ] \ + && [ "$(LC_ALL=C grep -cE "$NOTES_END_RE" "$CUR_BODY_FILE")" -eq 1 ]; then + CUR_HAS_CLEAN_NOTES=1 + fi + # Does the FRESH body carry exactly one clean begin+end pair? If a + # truncated/markerless fresh body would be used to overwrite an issue + # that HAS real notes, those notes are lost — so we require this too. + BODY_HAS_CLEAN_NOTES=0 + if [ "$(LC_ALL=C grep -cE "$NOTES_BEGIN_RE" "$BODY_FILE")" -eq 1 ] \ + && [ "$(LC_ALL=C grep -cE "$NOTES_END_RE" "$BODY_FILE")" -eq 1 ]; then + BODY_HAS_CLEAN_NOTES=1 + fi + + SKIP_EDIT=0 + # 1) Splice any human-authored notes from the live issue into the fresh + # body, replacing the freshly generated placeholder block. Require a + # COMPLETE, single begin+end marker pair in BOTH bodies — an + # unterminated or duplicated block would otherwise capture the entire + # stale report to EOF and re-inject it, growing the body every run. + if [ "$CUR_HAS_CLEAN_NOTES" -eq 1 ] && [ "$BODY_HAS_CLEAN_NOTES" -eq 1 ]; then + MERGED_BODY_FILE="$(mktemp)" + # Markers are matched as ANCHORED FULL LINES so a note that merely + # mentions the marker text cannot prematurely terminate capture. + # LC_ALL=C keeps awk's [[:space:]] ASCII-only, matching the grep guard. + LC_ALL=C awk ' + /^[[:space:]]*[[:space:]]*$/ { + if (FNR==NR) { cap=1; next } else { print; printf "%s", notes; skip=1; next } + } + /^[[:space:]]*[[:space:]]*$/ { + if (FNR==NR) { cap=0; next } else { print; skip=0; next } + } + FNR==NR { if (cap) { notes = notes $0 "\n" } ; next } + { if (!skip) print } + ' "$CUR_BODY_FILE" "$BODY_FILE" > "$MERGED_BODY_FILE" + mv "$MERGED_BODY_FILE" "$BODY_FILE" + echo "Preserved existing Release Captain Notes block." + elif [ "$CUR_HAS_CLEAN_NOTES" -eq 1 ] && [ "$BODY_HAS_CLEAN_NOTES" -ne 1 ]; then + # The live issue HAS clean notes but the freshly generated body does + # NOT carry a clean begin+end pair (e.g. truncated below the cap, or + # markers otherwise missing). Splicing is impossible and overwriting + # would wipe the live notes, so skip the edit entirely. Self-heals on + # the next run once the fresh body regains its markers. + echo "::warning::Fresh report for #${CANONICAL} lacks clean notes markers (truncated?); skipping edit to protect existing Release Captain Notes." + SKIP_EDIT=1 + elif [ "$CUR_HAS_CLEAN_NOTES" -ne 1 ] \ + && LC_ALL=C grep -q 'release-readiness:human-notes:' "$CUR_BODY_FILE"; then + # The live body carries notes markers that don't resolve to a single + # clean begin+end pair (corrupted, duplicated, or text on the marker + # line). We can't splice safely and overwriting would wipe the notes, + # so skip the edit entirely — self-heals once the markers are a clean + # pair again (a stale refresh recovers; destroyed captain notes do not). + echo "::warning::Issue #${CANONICAL} has malformed Release Captain Notes markers; skipping edit to protect them." + SKIP_EDIT=1 + fi + + # 1b) Final body-size guard. The awk splice above injects the LIVE + # notes block (which a captain may have grown to many KB) into the + # freshly capped body. The engines cap the FRESH body, reserving + # room only for the small notes PLACEHOLDER — they never see the + # live-notes size — so a busy report plus large notes can push the + # merged body past GitHub's 65,536-byte issue-body limit, which + # makes `gh issue edit` 422 and fail the run under set -e. Skip the + # edit instead (notes stay safe; the report just stays stale this + # run) and self-heal once the report or the notes shrink. `wc -c` + # counts bytes — matching the engines' byte-based cap — and is + # conservative against GitHub's character limit. + if [ "$SKIP_EDIT" -ne 1 ]; then + MERGED_SIZE=$(wc -c < "$BODY_FILE") + if [ "$MERGED_SIZE" -gt 65536 ]; then + echo "::warning::Body for #${CANONICAL} is ${MERGED_SIZE} bytes (> GitHub's 65536-byte limit) after splicing live notes; skipping edit to avoid a failed gh issue edit. Self-heals once the report or notes shrink." + SKIP_EDIT=1 + fi + fi + + # 2) Idempotent no-op: if the semantic hash is unchanged, skip the edit + # so scheduled re-runs don't spam watchers. The engine emits its hash + # at the very TOP of the body, ABOVE the human-notes block, so scope + # extraction to the pre-notes region with `sed '/begin/q'`. Anchoring + # the grep to the full HTML-comment form is not enough on its own: the + # `` line is exactly what a + # captain copies from a prior raw-markdown run and may paste INTO their + # notes; the splice then carries it into the fresh body. On Preview + # trackers (which emit NO hash and must refresh every run) that pasted + # line would make OLD_HASH==NEW_HASH and FREEZE the issue. Scoping to + # above the notes block makes any hash inside the notes invisible to the + # compare, regardless of paste form. The anchored grep keeps the match + # precise and drops a trailing CRLF \r from the captured hash. + if [ "$SKIP_EDIT" -ne 1 ]; then + OLD_HASH=$(sed '//q' "$CUR_BODY_FILE" | grep -oE '' | head -n1 | sed 's/.*sha=//; s/ -->//') || true + NEW_HASH=$(sed '//q' "$BODY_FILE" | grep -oE '' | head -n1 | sed 's/.*sha=//; s/ -->//') || true + if [ -n "$NEW_HASH" ] && [ "$OLD_HASH" = "$NEW_HASH" ]; then + echo "Semantic hash unchanged (${NEW_HASH}) — skipping issue edit (no-op)." + else + gh issue edit "$CANONICAL" \ + --repo "${{ github.repository }}" \ + --title "$ISSUE_TITLE" \ + --body-file "$BODY_FILE" + fi + fi + fi + else + echo "Creating new tracker issue for ${TRACKER_KEY}" + CREATE_ARGS=( + --repo "${{ github.repository }}" + --title "$ISSUE_TITLE" + --body-file "$BODY_FILE" + --label "report" + --label "s/triaged" + --label "area-release-readiness" + ) + # Best-effort milestone attach — never fail the job for a missing milestone. + if [ -n "$MILESTONE_NAME" ]; then + if gh api "repos/${{ github.repository }}/milestones?state=open&per_page=100" \ + --jq ".[] | select(.title == \"$MILESTONE_NAME\") | .number" \ + | grep -q .; then + CREATE_ARGS+=(--milestone "$MILESTONE_NAME") + else + echo "::warning::Milestone '$MILESTONE_NAME' not found; creating issue without milestone." + fi + fi + gh issue create "${CREATE_ARGS[@]}" + fi + + # ──────────────────────────────────────────────────────────────────── + # PR validation — run scripts without touching issues + # ──────────────────────────────────────────────────────────────────── + validate: + name: Validate (PR) + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Run unit tests + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + pwsh -NoProfile -File .github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 + + - name: Run Find-Trackers (no issue side-effects) + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + pwsh -NoProfile -File .github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1 \ + -AllActiveMajors \ + -OutputJson trackers.json + if [ ! -s trackers.json ]; then + echo "::error::Find-ReleaseReadinessTrackers produced no JSON" + exit 1 + fi + echo "Detection JSON sample (first 200 lines):" + head -200 trackers.json + + - name: Smoke-run report scripts for each detected tracker + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + mkdir -p validate-out + + # For each tracker, just smoke-test the report-generation path (~30s/tracker). + jq -c '.majors[].trackers[]' trackers.json | while IFS= read -r tracker; do + CANONICAL=$(echo "$tracker" | jq -r '.canonicalKey') + BRANCH_TYPE=$(echo "$tracker" | jq -r '.branchType') + BRANCH_NAME=$(echo "$tracker" | jq -r '.branchName') + SURVEY_REF=$(echo "$tracker" | jq -r '.surveyRef') + MODE=$(echo "$tracker" | jq -r '.mode') + PRIOR_SR=$(echo "$tracker" | jq -r '.priorSrBranch // ""') + REG_LABELS=$(echo "$tracker" | jq -r '.regressionLabels // [] | join(",")') + + OUT_DIR="validate-out/${CANONICAL}" + mkdir -p "$OUT_DIR" + + echo "::group::Validate ${CANONICAL} (${BRANCH_TYPE})" + if [ "$BRANCH_TYPE" = "sr" ]; then + # New-RegressionLabelList always emits ≥1 label, so the + # -InferRegressionLabels fallback is unreachable. Wire labels + # through directly and fail loudly if upstream regressed. + if [ -z "$REG_LABELS" ]; then + echo "::error::SR tracker $CANONICAL missing regressionLabels" + exit 1 + fi + REG_LABEL_ARG=(-RegressionLabels "$REG_LABELS") + CANDIDATE_ARG=() + if [ "$MODE" = "candidate" ]; then + SR_ARG="$PRIOR_SR" + CANDIDATE_ARG=(-Candidate) + else + SR_ARG="$BRANCH_NAME" + fi + pwsh -NoProfile -File .github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 \ + -SrBranch "$SR_ARG" \ + "${CANDIDATE_ARG[@]}" \ + "${REG_LABEL_ARG[@]}" \ + -TrackerKey "$CANONICAL" \ + -OutputDir "$OUT_DIR" + elif [ "$BRANCH_TYPE" = "preview" ]; then + pwsh -NoProfile -File .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ + -Branch "$BRANCH_NAME" \ + -Mode "$MODE" \ + -SurveyRef "$SURVEY_REF" \ + -TrackerKey "$CANONICAL" \ + -OutputDir "$OUT_DIR" \ + -OutputFormat markdown + fi + echo "::endgroup::" + done + + - name: Upload validation artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: release-readiness-validate + path: | + trackers.json + validate-out/ + retention-days: 7