From 68dabc2fa9f007925a1868fe864c0cb83d93f0cc Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Wed, 8 Jul 2026 13:54:32 +0200 Subject: [PATCH 01/10] Improve breaking-change-doc version detection and issue link Rework the breaking-change-doc skill to address dotnet/runtime#130184: Get-VersionInfo.ps1: determine the version from release-branch existence (with cadence-aware Preview->RC->GA rollover) plus own-commit and release-tag containment, instead of the unreliable tag+1 heuristic. Detect potential backports via the primary PR's cross-reference graph and report an earlier milestone as tentative (with a FallbackVersion) when an unverified linked release-branch PR is found. Build-IssueComment.ps1: build a hybrid pre-filled issue link that keeps the body in the URL only when it fits (~8000-byte budget) and otherwise falls back to a copy-paste code block, avoiding the 414 error. Source labels/assignee from the docs issue template and drop the redaction-prone mailto link. SKILL.md: document the above and remove duplicated/stale content. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-IssueComment.ps1 | 122 ++-- .../breaking-change-doc/Get-VersionInfo.ps1 | 523 ++++++++++++++---- .github/skills/breaking-change-doc/SKILL.md | 115 ++-- 3 files changed, 576 insertions(+), 184 deletions(-) diff --git a/.github/skills/breaking-change-doc/Build-IssueComment.ps1 b/.github/skills/breaking-change-doc/Build-IssueComment.ps1 index 6fb00936c92ab8..4e688e4bc0e318 100644 --- a/.github/skills/breaking-change-doc/Build-IssueComment.ps1 +++ b/.github/skills/breaking-change-doc/Build-IssueComment.ps1 @@ -1,16 +1,29 @@ # Build-IssueComment.ps1 -# Reads a breaking change issue draft markdown file, URL-encodes the title, -# body, and labels, and produces a brief PR comment markdown file containing: -# - A short header with instructions -# - @mentions of the PR assignees so they get notified -# - A clickable link that pre-fills a new issue in dotnet/docs -# - An email reminder +# Reads a breaking change issue draft markdown file and produces a PR comment +# markdown file that links to a pre-filled "new issue" page in dotnet/docs. +# +# Hybrid link strategy: +# * The link always targets `issues/new` directly (NOT `?template=`), which opens +# a blank markdown editor rather than the structured issue form. Short fields +# (title, labels, assignees) are always pre-filled from the query string. +# * If the full URL -- including the URL-encoded body -- stays under the GitHub +# request-line limit (~8192 bytes; we use a conservative 8000-byte budget on the +# full URL string), the body is pre-filled too, so the issue is one click away. +# * Otherwise the body is dropped from the URL and included in the comment inside a +#
block for the user to copy-paste into the opened (empty) editor. +# +# Labels and assignees are supplied by the caller and should come from the +# dotnet/docs breaking-change issue template (.github/ISSUE_TEMPLATE/02-breaking-change.yml). +# The runtime PR's assignees are passed separately as -CcMentions +# and only used for /cc notifications. # # Usage: # pwsh .github/skills/breaking-change-doc/Build-IssueComment.ps1 ` # -IssueDraftPath issue-draft.md ` # -Title "[Breaking change]: Something changed" ` -# -Assignees "@user1 @user2" ` +# -Labels "breaking-change" ` +# -IssueAssignees "gewarren" ` +# -CcMentions "@user1 @user2" ` # -OutputPath pr-comment.md # # The issue draft file should contain only the issue body markdown (no title). @@ -22,13 +35,23 @@ param( [Parameter(Mandatory = $true)] [string]$Title, - [string]$Assignees = "", + # Comma-separated labels, from the docs issue template's `labels:` field. + [string]$Labels = "", + + # Comma-separated GitHub usernames for the issue `assignees=` prefill, + # from the docs issue template's `assignees:` field. + [string]$IssueAssignees = "", + + # Space-separated @mentions (the runtime PR's assignees) for the /cc line. + [string]$CcMentions = "", [string]$OutputPath = "pr-comment.md", - [string]$Labels = "breaking-change,Pri1,doc-idea", + [string]$DocsRepo = "dotnet/docs", - [string]$DocsRepo = "dotnet/docs" + # Conservative budget on the full URL string length in bytes. GitHub's front end + # rejects a request line over ~8192 bytes with HTTP 414; 8000 leaves headroom. + [int]$MaxUrlBytes = 8000 ) $ErrorActionPreference = "Stop" @@ -40,21 +63,60 @@ if (-not (Test-Path $IssueDraftPath)) { $issueBody = Get-Content -Path $IssueDraftPath -Raw -Encoding UTF8 -# URL-encode using .NET Uri class (same technique the old script used) -$encodedTitle = [Uri]::EscapeDataString($Title) +# Build the query string for the short (always pre-filled) fields. +# [Uri]::EscapeDataString produces ASCII-only output, so the resulting URL string's +# character count equals its UTF-8 byte count. +$queryParts = @("title=$([Uri]::EscapeDataString($Title))") + +if (-not [string]::IsNullOrWhiteSpace($Labels)) { + $queryParts += "labels=$([Uri]::EscapeDataString($Labels.Trim()))" +} + +if (-not [string]::IsNullOrWhiteSpace($IssueAssignees)) { + # GitHub expects a comma-separated list for the assignees prefill. + $assigneesValue = ($IssueAssignees.Trim() -replace '\s*,\s*', ',') + $queryParts += "assignees=$([Uri]::EscapeDataString($assigneesValue))" +} + +$baseUrl = "https://github.com/$DocsRepo/issues/new?" + ($queryParts -join '&') + +# Try to also pre-fill the body if the full URL fits within the budget. $encodedBody = [Uri]::EscapeDataString($issueBody) -$encodedLabels = [Uri]::EscapeDataString($Labels) -$encodedEmailSubject = [Uri]::EscapeDataString("[Breaking Change] $Title") +$fullUrl = "$baseUrl&body=$encodedBody" +$bodyInUrl = $fullUrl.Length -le $MaxUrlBytes -$issueUrl = "https://github.com/$DocsRepo/issues/new?title=$encodedTitle&body=$encodedBody&labels=$encodedLabels" -$notificationEmailUrl = "mailto:dotnetbcn@microsoft.com?subject=$encodedEmailSubject" +$issueUrl = if ($bodyInUrl) { $fullUrl } else { $baseUrl } -# Build a brief assignee mention line (if any) +# Build the /cc mention line (from the runtime PR's assignees). $mentionLine = "" -$trimmedAssignees = "" -if (-not [string]::IsNullOrWhiteSpace($Assignees)) { - $trimmedAssignees = $Assignees.Trim() - $mentionLine = "`n`n/cc $trimmedAssignees" +if (-not [string]::IsNullOrWhiteSpace($CcMentions)) { + $mentionLine = "`n`n/cc $($CcMentions.Trim())" +} + +# When the body isn't pre-filled, include it in the comment for copy-paste. +# The body is wrapped in a fenced code block so the raw markdown can be copied +# verbatim. A four-backtick fence is used because the body itself contains +# three-backtick code fences. (Built from a single-quoted variable so PowerShell's +# backtick escaping doesn't collapse it.) +$fence = '````' +$copyPasteSection = "" +if (-not $bodyInUrl) { + $copyPasteSection = @" + + +The issue body is too long to pre-fill in the link above, so the link opens a +**blank issue** with only the title, labels, and assignees set. Copy the body +below into the description field before submitting. + +
+Issue body (click to expand, then copy) + +$($fence)md +$issueBody +$fence + +
+"@ } $comment = @" @@ -62,27 +124,23 @@ $comment = @" A breaking change draft has been prepared for this PR. -:point_right: **[Click here to create the issue in $DocsRepo]($issueUrl)** +:point_right: **[Click here to create the issue in $DocsRepo]($issueUrl)**$copyPasteSection -After creating the issue, please email a link to it to -[.NET Breaking Change Notifications]($notificationEmailUrl).$mentionLine +After creating the issue, please email a link to it to the +.NET Breaking Change Notifications alias (dotnetbcn@microsoft.com).$mentionLine > [!NOTE] > This documentation was generated with AI assistance from Copilot. "@ -# GitHub comment body limit is 65536 characters. The comment is now brief, -# but the URL itself can be very long. Warn if the comment exceeds the -# configured comment-length threshold; the URL length is checked separately below. +# GitHub comment body limit is 65536 characters. $maxCommentLength = 65000 if ($comment.Length -gt $maxCommentLength) { - Write-Warning "Comment body ($($comment.Length) chars) exceeds GitHub limit." + Write-Warning "Comment body ($($comment.Length) chars) exceeds GitHub's comment length limit. The issue body may be too large to embed." } $comment | Out-File -FilePath $OutputPath -Encoding UTF8 -NoNewline Write-Host "Wrote PR comment to $OutputPath ($($comment.Length) characters)" -Write-Host "Issue URL length: $($issueUrl.Length) characters" -if ($issueUrl.Length -gt 8192) { - Write-Warning "URL exceeds 8192 characters. Some browsers may truncate it. Consider shortening the issue body." -} +Write-Host "Body pre-filled in URL: $bodyInUrl" +Write-Host "Issue URL length: $($issueUrl.Length) bytes (budget: $MaxUrlBytes)" diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index 76332081aa3d38..1ad0739c5dbf95 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -1,10 +1,24 @@ # Get-VersionInfo.ps1 # Determines the .NET version context for a merged PR using the GitHub CLI (gh). # +# The reliable signal is the existence of release branches named +# release/.-preview (and later -rc / GA release/.) +# Once release/.-preview exists, that preview is branched and locked, so a +# change merged to main lands in the *next* milestone -- following the standard .NET +# cadence of $PreviewCount previews, then $RcCount RCs, then GA (so Preview 7 rolls +# over to RC 1, and RC 2 to GA). Exceptions: +# * If the change was backported into an already-branched milestone, it ships there. +# A merged backport is found via commit containment (compare API); an unmerged one +# is found via the backport PR and reported as tentative. +# * For changes that already shipped in a past major (whose preview/RC branches were +# pruned), containment resolves only to the GA line, so release *tags* (which are +# never pruned) are scanned to recover the exact first preview/RC. +# # Usage: # pwsh .github/skills/breaking-change-doc/Get-VersionInfo.ps1 -PrNumber 114929 # -# Output: JSON object with LastTagBeforeMerge, FirstTagWithChange, EstimatedVersion +# Output: JSON object with EstimatedVersion, Tentative, DetectionMethod, HighestBranch, +# ContainedInBranch, FirstShippedTag, Backports, MergeCommit, MergedAt, BaseRef. param( [Parameter(Mandatory = $true)] @@ -12,185 +26,466 @@ param( [string]$SourceRepo = "dotnet/runtime", - [string]$BaseRef = "" + [string]$BaseRef = "", + + # .NET release cadence: this many previews, then this many RCs, then GA. + # Used to predict the next milestone across Preview->RC and RC->GA transitions. + [int]$PreviewCount = 7, + + [int]$RcCount = 2 ) $ErrorActionPreference = "Stop" -function ConvertFrom-DotNetTag { - param([string]$tagName) - - if (-not $tagName -or $tagName -eq "Unknown") { +# Parse a release branch name into a structured milestone. +# Recognizes: +# release/11.0 -> GA +# release/11.0-preview6 -> Preview 6 +# release/11.0-rc1 -> RC 1 +# release/11.0-staging -> GA (staging variant, treated as GA milestone) +# Returns $null for non-release or unrecognized branch names. +function ConvertFrom-ReleaseBranch { + param([string]$branchName) + + if ($branchName -notmatch '^release/(\d+)\.(\d+)(?:-([a-zA-Z]+)(\d+)?)?$') { return $null } - if ($tagName -match '^v(\d+)\.(\d+)\.(\d+)(?:-(.+))?$') { - $major = [int]$matches[1] - $minor = [int]$matches[2] - $build = [int]$matches[3] - $prerelease = if ($matches[4]) { $matches[4] } else { $null } + $major = [int]$matches[1] + $minor = [int]$matches[2] + $label = if ($matches[3]) { $matches[3].ToLower() } else { $null } + $number = if ($matches[4]) { [int]$matches[4] } else { $null } + + # Milestone ordering rank within a major.minor: previews < rc < GA. + $kind = "ga" + $rank = 3000 + switch ($label) { + "preview" { $kind = "preview"; $rank = 1000 + ($number ?? 0) } + "rc" { $kind = "rc"; $rank = 2000 + ($number ?? 0) } + "staging" { $kind = "ga"; $rank = 3000 } + $null { $kind = "ga"; $rank = 3000 } + default { $kind = $label; $rank = 0 } + } - $prereleaseType = $null - $prereleaseNumber = $null + return [pscustomobject]@{ + Major = $major + Minor = $minor + Kind = $kind + Number = $number + Rank = $rank + Branch = $branchName + } +} - if ($prerelease -and $prerelease -match '^([a-zA-Z]+)\.(\d+)') { - $rawType = $matches[1] - $prereleaseNumber = [int]$matches[2] +# Format a milestone (kind + number) for a given major.minor as a docs-template +# version string, e.g. ".NET 11 Preview 7", ".NET 11 RC 1", ".NET 11". +function Format-Milestone { + param([int]$major, [int]$minor, [string]$kind, [Nullable[int]]$number) + + $baseVersion = ".NET $major" + if ($minor -ne 0) { + $baseVersion = ".NET $major.$minor" + } + + switch ($kind) { + "preview" { return "$baseVersion Preview $number" } + "rc" { return "$baseVersion RC $number" } + default { return $baseVersion } + } +} + +# Compute the version a change merged to main would ship in, given the release branches +# already cut for its major.minor. Follows the .NET cadence: $previewCount previews, +# then $rcCount RCs, then GA -- so the milestone *after* the highest branched one, with +# Preview N -> RC 1 and RC N -> GA rollovers. Returns the version string, the highest +# branch name, and a human-readable detection note. +function Get-NextMilestoneVersion { + param( + [pscustomobject[]]$milestones, + [pscustomobject]$devVersion, + [string]$baseRef, + [int]$previewCount, + [int]$rcCount + ) + + if (-not $milestones -or $milestones.Count -eq 0) { + return [pscustomobject]@{ + Version = Format-Milestone $devVersion.Major $devVersion.Minor "preview" 1 + HighestBranch = "(none)" + Detection = "Merged to '$baseRef'; no milestone branched yet for this version" + } + } - if ($rawType -ieq "rc") { - $prereleaseType = "RC" + $highest = $milestones | Sort-Object Rank -Descending | Select-Object -First 1 + + switch ($highest.Kind) { + "preview" { + $version = if ($highest.Number -lt $previewCount) { + Format-Milestone $devVersion.Major $devVersion.Minor "preview" ($highest.Number + 1) } else { - $prereleaseType = $rawType.Substring(0, 1).ToUpper() + $rawType.Substring(1).ToLower() + # Final preview branched -> next milestone is RC 1. + Format-Milestone $devVersion.Major $devVersion.Minor "rc" 1 } } - - return @{ - Major = $major - Minor = $minor - Build = $build - Prerelease = $prerelease - PrereleaseType = $prereleaseType - PrereleaseNumber = $prereleaseNumber - IsRelease = $null -eq $prerelease + "rc" { + $version = if ($highest.Number -lt $rcCount) { + Format-Milestone $devVersion.Major $devVersion.Minor "rc" ($highest.Number + 1) + } else { + # Final RC branched -> next milestone is GA. + Format-Milestone $devVersion.Major $devVersion.Minor "ga" $null + } } + default { $version = Format-Milestone $devVersion.Major $devVersion.Minor "ga" $null } } - return $null + return [pscustomobject]@{ + Version = $version + HighestBranch = $highest.Branch + Detection = "Merged to '$baseRef'; ships after highest branched milestone '$($highest.Branch)' (assumes $previewCount previews + $rcCount RCs before GA)" + } } -function Format-DotNetVersion { - param($parsedTag) +# Parse a .NET release tag (e.g. v11.0.0-preview.7.25380.108, v10.0.0-rc.1.xxx, +# v10.0.0, v10.0.3) into a structured milestone. Unlike release branches, tags are +# never pruned, so they preserve preview/RC granularity for already-shipped versions. +function ConvertFrom-DotNetTag { + param([string]$tagName) - if (-not $parsedTag) { - return "Next release" + if ($tagName -notmatch '^v(\d+)\.(\d+)\.(\d+)(?:-(.+))?$') { + return $null } - $baseVersion = ".NET $($parsedTag.Major).$($parsedTag.Minor)" - - if ($parsedTag.IsRelease) { - return $baseVersion + $major = [int]$matches[1] + $minor = [int]$matches[2] + $build = [int]$matches[3] + $prerelease = if ($matches[4]) { $matches[4] } else { $null } + + $kind = "ga" + $number = $null + if ($prerelease) { + if ($prerelease -match '^([a-zA-Z]+)\.(\d+)') { + $raw = $matches[1] + $number = [int]$matches[2] + $kind = if ($raw -ieq "rc") { "rc" } elseif ($raw -ieq "preview") { "preview" } else { $raw.ToLower() } + } else { + $kind = "prerelease" + } } - if ($parsedTag.PrereleaseType -and $parsedTag.PrereleaseNumber) { - return "$baseVersion $($parsedTag.PrereleaseType) $($parsedTag.PrereleaseNumber)" + return [pscustomobject]@{ + Major = $major + Minor = $minor + Build = $build + Kind = $kind + Number = $number + IsRelease = ($null -eq $prerelease) + Tag = $tagName } - - return "$baseVersion ($($parsedTag.Prerelease))" } -function Get-EstimatedNextVersion { - param($parsedTag, [string]$baseRef) +# Ordering key so preview < rc < GA within a patch, and lower patch first. +function Get-TagSortKey { + param($t) - if (-not $parsedTag) { - return "Next release" + $sub = switch ($t.Kind) { + "preview" { 1000 + ($t.Number ?? 0) } + "rc" { 2000 + ($t.Number ?? 0) } + "ga" { 3000 } + default { 500 } } + return ($t.Build * 100000) + $sub +} - $isMainBranch = $baseRef -eq "main" +# Find the earliest published tag for a given major.minor that already contains the +# merge commit. Used to recover exact preview/RC granularity when branch containment +# only resolved to a (pruned) GA line. Returns the parsed tag, or $null. +function Get-EarliestContainingTag { + param([string]$repo, [string]$commit, [int]$major, [int]$minor) - if ($parsedTag.IsRelease) { - if ($isMainBranch) { - $nextMajor = $parsedTag.Major + 1 - return ".NET $nextMajor.0 Preview 1" - } else { - return ".NET $($parsedTag.Major).$($parsedTag.Minor)" + if (-not $commit) { return $null } + + $tagNames = @(gh api "repos/$repo/tags" --paginate --jq '.[].name' 2>$null) + if ($LASTEXITCODE -ne 0) { return $null } + + $candidates = @( + $tagNames | + ForEach-Object { ConvertFrom-DotNetTag $_ } | + Where-Object { $_ -and $_.Major -eq $major -and $_.Minor -eq $minor } | + Sort-Object { Get-TagSortKey $_ } + ) + + foreach ($t in $candidates) { + if (Test-CommitInBranch -repo $repo -commit $commit -branch $t.Tag) { + return $t } } + return $null +} + +# Read MajorVersion/MinorVersion from eng/Versions.props at a specific ref. +# This is the authoritative source for the in-development major.minor on main. +function Get-DevMajorMinor { + param([string]$repo, [string]$ref) + + try { + $content = gh api "repos/$repo/contents/eng/Versions.props?ref=$ref" -H "Accept: application/vnd.github.raw" 2>$null + if ($LASTEXITCODE -ne 0 -or -not $content) { + return $null + } + $text = ($content | Out-String) + if ($text -match '(\d+)' ) { + $maj = [int]$matches[1] + $min = 0 + if ($text -match '(\d+)') { + $min = [int]$matches[1] + } + return [pscustomobject]@{ Major = $maj; Minor = $min } + } + } catch { } + + return $null +} - if ($isMainBranch -and $parsedTag.PrereleaseType -eq "RC") { - $nextMajor = $parsedTag.Major + 1 - return ".NET $nextMajor.0 Preview 1" - } else { - $nextPreview = $parsedTag.PrereleaseNumber + 1 - return ".NET $($parsedTag.Major).$($parsedTag.Minor) $($parsedTag.PrereleaseType) $nextPreview" +# Is the merge commit already contained in the given branch? +# True when the milestone branched after the PR merged (normal case), or when a +# backport was already merged into the branch. +function Test-CommitInBranch { + param([string]$repo, [string]$commit, [string]$branch) + + if (-not $commit) { return $false } + $behind = gh api "repos/$repo/compare/$commit...$branch" --jq '.behind_by' 2>$null + return ($LASTEXITCODE -eq 0 -and $behind -match '^\d+$' -and [int]$behind -eq 0) +} + +# Find *potential* backport PRs for a source PR via GitHub's cross-reference graph. +# A backport PR is almost always cross-referenced from the primary PR (through its +# body, a comment, or the backport tooling), regardless of its head-branch name -- +# which makes this far more robust than matching a branch naming convention (real +# backports use ad-hoc names, and branches are deleted after merge). The primary PR's +# timeline yields candidate PR numbers; a single GraphQL batch resolves each one's +# base branch and state; and we keep only those targeting a release branch. +# +# NOTE: these are *candidates only*. A cross-reference does not prove a PR is a +# backport of this change, so the caller (skill) should confirm via the PR's +# title/description/diff before trusting it. +function Get-BackportPRs { + param([string]$repo, [string]$prNumber) + + # Cross-references can point at PRs in *other* repos (e.g. VMR codeflow PRs from + # dotnet/dotnet). Filter to same-repo PRs up front (via source.issue.repository) + # so the GraphQL batch only asks for numbers that exist in this repo. + $candidateNumbers = @( + gh api "repos/$repo/issues/$prNumber/timeline" --paginate ` + --jq ".[] | select(.event==`"cross-referenced`") | select(.source.issue.pull_request != null) | select(.source.issue.repository.full_name == `"$repo`") | .source.issue.number" 2>$null | + Sort-Object -Unique + ) + if ($candidateNumbers.Count -eq 0) { return @() } + + $parts = $repo -split '/' + $owner = $parts[0] + $name = $parts[1] + + # Build a single batched GraphQL query with one alias per candidate. + $aliases = for ($i = 0; $i -lt $candidateNumbers.Count; $i++) { + "p${i}: pullRequest(number: $($candidateNumbers[$i])) { number state merged baseRefName title }" } + $query = 'query($owner:String!,$name:String!){ repository(owner:$owner,name:$name){ ' + ($aliases -join ' ') + ' } }' + + $resp = gh api graphql -f owner=$owner -f name=$name -f query=$query 2>$null + if ($LASTEXITCODE -ne 0 -or -not $resp) { return @() } + $repository = ($resp | ConvertFrom-Json).data.repository + if (-not $repository) { return @() } + + $results = @() + foreach ($prop in $repository.PSObject.Properties) { + $pr = $prop.Value + if (-not $pr) { continue } + # Only PRs targeting a release branch are plausible backports. + $milestone = ConvertFrom-ReleaseBranch $pr.baseRefName + if (-not $milestone) { continue } + $results += [pscustomobject]@{ + Number = $pr.number + State = $pr.state # OPEN | CLOSED | MERGED + Merged = [bool]$pr.merged + TargetBranch = $pr.baseRefName + Title = $pr.title + Milestone = $milestone + } + } + + return $results } try { - # Step 1: Get PR merge info via GitHub CLI + # Step 1: PR merge info $prJson = gh pr view $PrNumber --repo $SourceRepo --json mergeCommit,mergedAt,baseRefName 2>$null if ($LASTEXITCODE -ne 0) { throw "Failed to fetch PR #$PrNumber from $SourceRepo" } $prData = $prJson | ConvertFrom-Json - $targetCommit = $prData.mergeCommit.oid + $mergeCommit = $prData.mergeCommit.oid $mergedAt = $prData.mergedAt - if (-not $BaseRef) { $BaseRef = $prData.baseRefName } - # Step 2: Get recent releases (tags with published dates) in a single API call - $releasesJson = gh release list --repo $SourceRepo --limit 100 --json tagName,publishedAt 2>$null - $releases = @() - if ($LASTEXITCODE -eq 0 -and $releasesJson) { - $releases = @($releasesJson | ConvertFrom-Json) + # Step 2: If the PR merged directly into a release branch, the milestone is that + # branch's milestone -- no further estimation needed. + $baseMilestone = if ($BaseRef -match '^release/') { ConvertFrom-ReleaseBranch $BaseRef } else { $null } + + if ($baseMilestone) { + $estimated = Format-Milestone $baseMilestone.Major $baseMilestone.Minor $baseMilestone.Kind $baseMilestone.Number + @{ + EstimatedVersion = $estimated + Tentative = $false + DetectionMethod = "PR merged directly into release branch '$BaseRef'" + MergeCommit = $mergeCommit + MergedAt = $mergedAt + BaseRef = $BaseRef + } | ConvertTo-Json + return } - # Filter to .NET version tags (v{major}.{minor}.{patch}[-prerelease]) with a valid publishedAt - $versionReleases = @($releases | Where-Object { $_.tagName -match '^v\d+\.\d+\.\d+' -and $_.publishedAt }) + # Step 3: Determine the in-development major.minor for main. + $devVersion = Get-DevMajorMinor -repo $SourceRepo -ref $mergeCommit + if (-not $devVersion) { + $devVersion = Get-DevMajorMinor -repo $SourceRepo -ref $BaseRef + } + + # Step 4: Enumerate release branches for that major.minor. + $branchNames = @(gh api "repos/$SourceRepo/branches" --paginate --jq '.[].name' 2>$null) + if ($LASTEXITCODE -ne 0) { + throw "Failed to list branches for $SourceRepo" + } - $lastTagBefore = "Unknown" - $firstTagWith = "Not yet released" + $allMilestones = @($branchNames | ForEach-Object { ConvertFrom-ReleaseBranch $_ } | Where-Object { $_ -ne $null }) - if ($mergedAt -and $versionReleases.Count -gt 0) { - $mergedAtDate = [DateTimeOffset]::Parse($mergedAt) + # If we could not read the dev version, fall back to the highest major.minor + # that has any release branch. + if (-not $devVersion -and $allMilestones.Count -gt 0) { + $top = $allMilestones | Sort-Object Major, Minor -Descending | Select-Object -First 1 + $devVersion = [pscustomobject]@{ Major = $top.Major; Minor = $top.Minor } + } - # Find the most recent release published before the merge - $beforeMerge = @($versionReleases | - Where-Object { [DateTimeOffset]::Parse($_.publishedAt) -lt $mergedAtDate } | - Sort-Object { [DateTimeOffset]::Parse($_.publishedAt) } -Descending) + if (-not $devVersion) { + throw "Could not determine the in-development major.minor version." + } - if ($beforeMerge.Count -gt 0) { - $lastTagBefore = $beforeMerge[0].tagName + $milestones = @($allMilestones | Where-Object { $_.Major -eq $devVersion.Major -and $_.Minor -eq $devVersion.Minor }) + + # Collect *potential* backport PRs (cross-referenced from this PR and targeting a + # release branch). Surface all of them -- including cross-major servicing backports + # -- as informational output; they are candidates the skill should confirm. + $backports = @(Get-BackportPRs -repo $SourceRepo -prNumber $PrNumber) + $backportInfo = @($backports | ForEach-Object { + [pscustomobject]@{ Number = $_.Number; State = $_.State; Merged = $_.Merged; Target = $_.TargetBranch; Title = $_.Title } + }) + + # Step 5a: If *this PR's merge commit itself* is contained in an existing milestone + # branch, the change definitively ships in that milestone (lowest such milestone). + # This is the normal "milestone branched after merge" case. Note it does NOT catch + # cherry-pick backports, whose commit differs from this PR's -- those are handled + # (tentatively) in Step 5b. + $containing = $null + foreach ($m in ($milestones | Sort-Object Rank)) { + if (Test-CommitInBranch -repo $SourceRepo -commit $mergeCommit -branch $m.Branch) { + $containing = $m + break } + } - # Find candidate releases published at or after the merge, oldest first - $afterMerge = @($versionReleases | - Where-Object { [DateTimeOffset]::Parse($_.publishedAt) -ge $mergedAtDate } | - Sort-Object { [DateTimeOffset]::Parse($_.publishedAt) }) - - # Verify containment via the compare API: behind_by == 0 means the tag - # includes every commit reachable from the merge commit. - if ($targetCommit -and $afterMerge.Count -gt 0) { - foreach ($release in $afterMerge) { - $tag = $release.tagName - $behindBy = gh api "repos/$SourceRepo/compare/${targetCommit}...${tag}" --jq '.behind_by' 2>$null - if ($LASTEXITCODE -eq 0 -and $behindBy -match '^\d+$' -and [int]$behindBy -eq 0) { - $firstTagWith = $tag - break - } + if ($containing) { + $detection = "Merge commit is already contained in branched milestone '$($containing.Branch)'" + $firstShippedTag = $null + $estimated = Format-Milestone $containing.Major $containing.Minor $containing.Kind $containing.Number + + # Refinement: preview/RC branches are pruned after a major ships, so containment + # on a GA branch loses granularity. Recover the exact first preview/RC from the + # persistent release tags. + if ($containing.Kind -eq "ga") { + $tag = Get-EarliestContainingTag -repo $SourceRepo -commit $mergeCommit -major $containing.Major -minor $containing.Minor + if ($tag) { + $estimated = Format-Milestone $tag.Major $tag.Minor $tag.Kind $tag.Number + $firstShippedTag = $tag.Tag + $detection = "First shipped in tag '$($tag.Tag)' (refined from GA branch '$($containing.Branch)')" } } + + @{ + EstimatedVersion = $estimated + Tentative = $false + DetectionMethod = $detection + HighestBranch = ($milestones | Sort-Object Rank -Descending | Select-Object -First 1).Branch + ContainedInBranch = $containing.Branch + FirstShippedTag = $firstShippedTag + Backports = $backportInfo + MergeCommit = $mergeCommit + MergedAt = $mergedAt + BaseRef = $BaseRef + } | ConvertTo-Json -Depth 5 + return } - # Step 3: Estimate version - $estimatedVersion = "Next release" + # Step 5b: Not contained by commit. A *linked* PR targeting an earlier branched + # milestone of the same major.minor suggests the change may also ship there (e.g. + # via a cherry-pick backport, whose commit differs from this PR's so containment + # can't see it). But a "backport" here is only "a linked PR to a release branch" and + # is NOT verified, so it is never definitive: report that milestone but mark the + # version **tentative** for the skill to confirm. Consider OPEN and MERGED linked + # PRs (a merged one may already be shipped there); ignore CLOSED (abandoned) ones. + $tentativeBackport = $backports | + Where-Object { ($_.State -eq 'OPEN' -or $_.State -eq 'MERGED') -and $_.Milestone -and $_.Milestone.Major -eq $devVersion.Major -and $_.Milestone.Minor -eq $devVersion.Minor } | + Sort-Object { $_.Milestone.Rank } | + Select-Object -First 1 + + if ($tentativeBackport) { + $bm = $tentativeBackport.Milestone + $estimated = Format-Milestone $bm.Major $bm.Minor $bm.Kind $bm.Number + $stateNote = if ($tentativeBackport.State -eq 'MERGED') { + "merged into '$($tentativeBackport.TargetBranch)' (may already ship there)" + } else { + "open against '$($tentativeBackport.TargetBranch)' (would ship there if it merges)" + } - if ($firstTagWith -ne "Not yet released") { - $parsedFirstTag = ConvertFrom-DotNetTag $firstTagWith - $estimatedVersion = Format-DotNetVersion $parsedFirstTag - } else { - $parsedLastTag = ConvertFrom-DotNetTag $lastTagBefore - $estimatedVersion = Get-EstimatedNextVersion $parsedLastTag $BaseRef + # The version if the linked PR turns out NOT to be a genuine backport: the + # normal "ships after highest branched milestone" result. Provided so the skill + # doesn't have to re-derive the cadence rollover itself. + $fallback = Get-NextMilestoneVersion -milestones $milestones -devVersion $devVersion -baseRef $BaseRef -previewCount $PreviewCount -rcCount $RcCount + + @{ + EstimatedVersion = $estimated + Tentative = $true + DetectionMethod = "Potential backport PR #$($tentativeBackport.Number) $stateNote; version is tentative -- confirm the PR is a genuine backport of this change (title/description/diff). If it is NOT, use FallbackVersion instead." + FallbackVersion = $fallback.Version + HighestBranch = $fallback.HighestBranch + Backports = $backportInfo + MergeCommit = $mergeCommit + MergedAt = $mergedAt + BaseRef = $BaseRef + } | ConvertTo-Json -Depth 5 + return } - # Output as JSON + # Step 6: Not contained and no relevant linked PR -> ships in the milestone *after* + # the highest branched one (cadence-aware; see Get-NextMilestoneVersion). + $next = Get-NextMilestoneVersion -milestones $milestones -devVersion $devVersion -baseRef $BaseRef -previewCount $PreviewCount -rcCount $RcCount + @{ - LastTagBeforeMerge = $lastTagBefore - FirstTagWithChange = $firstTagWith - EstimatedVersion = $estimatedVersion - MergeCommit = $targetCommit - MergedAt = $mergedAt - BaseRef = $BaseRef - } | ConvertTo-Json + EstimatedVersion = $next.Version + Tentative = $false + DetectionMethod = $next.Detection + HighestBranch = $next.HighestBranch + Backports = $backportInfo + MergeCommit = $mergeCommit + MergedAt = $mergedAt + BaseRef = $BaseRef + } | ConvertTo-Json -Depth 5 } catch { - # Return error info as JSON so the agent can handle it @{ - LastTagBeforeMerge = "Unknown" - FirstTagWithChange = "Not yet released" - EstimatedVersion = "Next release" - Error = $_.Exception.Message + EstimatedVersion = "Next release" + DetectionMethod = "error" + Error = $_.Exception.Message } | ConvertTo-Json } diff --git a/.github/skills/breaking-change-doc/SKILL.md b/.github/skills/breaking-change-doc/SKILL.md index ced83b253d7e50..1f167893781a78 100644 --- a/.github/skills/breaking-change-doc/SKILL.md +++ b/.github/skills/breaking-change-doc/SKILL.md @@ -34,8 +34,7 @@ migration guidance. This skill automates that process: at `.github/workflows/breaking-change-doc.md` triggers when a PR labeled `needs-breaking-change-doc-created` is merged (or the label is added to an already-merged PR). It can also be run manually via `workflow_dispatch` with - an optional `suppress_output` flag for dry-run inspection. The compiled - workflow (`.lock.yml`) must be regenerated with `gh aw compile` after changes. + an optional `suppress_output` flag for dry-run inspection. ### Files @@ -44,8 +43,8 @@ migration guidance. This skill automates that process: | `.github/workflows/breaking-change-doc.md` | gh-aw workflow — triggers on PR merge/label | | `.github/workflows/breaking-change-doc.lock.yml` | Compiled workflow (generated by `gh aw compile`) | | `.github/skills/breaking-change-doc/SKILL.md` | This skill | -| `.github/skills/breaking-change-doc/Get-VersionInfo.ps1` | `gh` CLI-based .NET version detection | -| `.github/skills/breaking-change-doc/Build-IssueComment.ps1` | Builds PR comment with URL-encoded issue creation link | +| `.github/skills/breaking-change-doc/Get-VersionInfo.ps1` | Release-branch/tag-based .NET version detection (via `gh` CLI) | +| `.github/skills/breaking-change-doc/Build-IssueComment.ps1` | Builds PR comment with a hybrid pre-filled issue creation link | --- @@ -56,7 +55,7 @@ The user provides one of: - A PR URL (e.g. `https://github.com/dotnet/runtime/pull/114929`) - A request like "document the breaking change in PR 114929" -Extract the PR number. The source repository is always `dotnet/runtime`. +The source repository is always `dotnet/runtime`. --- @@ -105,19 +104,60 @@ Run the helper script to determine the .NET version context: pwsh .github/skills/breaking-change-doc/Get-VersionInfo.ps1 -PrNumber ``` -**You MUST display the complete script output.** The script outputs JSON that includes: -- `LastTagBeforeMerge` — the closest release tag before the merge commit -- `FirstTagWithChange` — the first tag that contains this commit (or "Not yet released") -- `EstimatedVersion` — human-readable version string like ".NET 11 Preview 3" +**You MUST display the complete script output.** The script determines the version +from the existence of `release/.-preview` (and later `-rc` / GA) +branches: once a milestone is branched it is locked, so a change merged to `main` +ships in the *next* milestone. Two things can move it into an earlier, already-branched +milestone: the merge commit is already contained in that branch (compare API), or a +**linked PR** (open or merged) targets that branch — in the latter case the version is +reported as **tentative**, because such a linked PR is only "a PR that references this +one and targets a release branch" and is not verified to be a genuine backport (and a +cherry-pick backport's commit differs from this PR's, so containment can't confirm it). Backports are discovered via the primary PR's cross-reference graph +(GitHub timeline), so they are found regardless of the backport's head-branch name. +For predicting the *next, unbranched* milestone the script assumes the standard .NET +cadence of **7 previews, then 2 RCs, then GA** (tunable via `-PreviewCount` / +`-RcCount`), so it correctly rolls over Preview 7 → RC 1 and RC 2 → GA. For +changes that already shipped in a *past* major (whose preview/RC branches have been +pruned), containment only resolves to the GA line, so the script then scans the +persistent release **tags** to recover the exact first preview/RC. The JSON output +includes: +- `EstimatedVersion` — human-readable version string like ".NET 11 Preview 7" +- `Tentative` — `true` when the version depends on an unverified linked release-branch + PR (open or merged); treat the version as provisional and say so in the draft +- `DetectionMethod` — how the version was determined (direct release-branch merge, + own-commit containment in a branched milestone, tag-refined first-shipped version, + potential linked backport, or "ships after highest branched milestone") +- `FallbackVersion` — present only when `Tentative` is `true`: the version to use if + the linked PR turns out **not** to be a genuine backport (the normal "ships after + highest branched milestone" result), so you don't have to re-derive it +- `HighestBranch` — the highest release branch found for the target major.minor +- `ContainedInBranch` — present only when the merge commit is already in a branch +- `FirstShippedTag` — set when the version was refined from release tags (the exact + tag where the change first shipped, e.g. `v9.0.0-preview.4.24266.19`) +- `Backports` — array of **potential** backport PRs cross-referenced from this PR and + targeting a release branch (`Number`, `State`, `Merged`, `Target`, `Title`), + including cross-major servicing backports (informational; they do not change the + primary version) - `MergeCommit` — the merge commit SHA - `MergedAt` — when the PR was merged - `BaseRef` — the PR base branch used to determine version context +**The `Backports` entries are candidates, not confirmed backports.** A cross-reference +only means the PR mentions this one; it does not prove it is a backport of this change. +When a backport is relevant to the outcome (especially a tentative version, or when +listing backports in the draft), **confirm it is a genuine backport** by inspecting the +candidate PR's title, description, and — if still unclear — its code diff, before +relying on it. + After running the script, **print the full JSON output** so it is visible in the workflow log. Then check the JSON: - If the JSON contains an `Error` field, report the error and fall back to - reading the PR's base branch and recent tags manually to estimate the version. + inspecting the release branches manually (`gh api repos/dotnet/runtime/branches`) + to estimate the version. +- If `Tentative` is `true`, confirm the referenced linked PR is a genuine backport of + this change (title/description/diff). If it is, use `EstimatedVersion` and note in the + draft that it depends on that backport; if it is not, use `FallbackVersion` instead. - If the JSON does **not** contain an `Error` field, use `EstimatedVersion` as the version for the breaking change issue. **Do not fall back to manual detection when the script succeeds.** @@ -147,6 +187,10 @@ gh api repos/dotnet/docs/contents/.github/ISSUE_TEMPLATE/02-breaking-change.yml This template defines the required sections and dropdown values. Use it as a structural reference only — do **not** output YAML. +**Also capture the template's `labels:` and `assignees:` values** — these are the +authoritative source for the labels and assignee applied to the new issue and must +be passed to `Build-IssueComment.ps1` in Step 6. + ### Example issues Search `dotnet/docs` for 2-3 recent issues with the `breaking-change` label. @@ -228,37 +272,38 @@ Create the output directory and write the full markdown content from Step 5 `artifacts/docs/breakingChanges/issue-draft.md`. This file is the primary output and can be reviewed before taking any further action. -```bash -mkdir -p artifacts/docs/breakingChanges -``` - ### Build the PR comment file -Run the helper script to produce a PR comment with a URL-encoded issue link. -Pass the PR assignees so they are `@`-mentioned in the comment and receive a -GitHub notification: +Run the helper script to produce a PR comment with a pre-filled issue link. +Pass the labels and assignee **from the docs issue template** (Step 4) for the new +issue, and pass the runtime PR's assignees separately so they are `@`-mentioned: ```bash pwsh .github/skills/breaking-change-doc/Build-IssueComment.ps1 \ -IssueDraftPath artifacts/docs/breakingChanges/issue-draft.md \ -Title "" \ - -Assignees "@user1 @user2" \ + -Labels "" \ + -IssueAssignees "" \ + -CcMentions "@user1 @user2" \ -OutputPath artifacts/docs/breakingChanges/pr-comment.md ``` -Build the `-Assignees` value from the PR's assignee list (from Step 1 metadata), -prefixing each GitHub username with `@` and separating with spaces. +- `-Labels` and `-IssueAssignees` come from the dotnet/docs template captured in + Step 4 (e.g. `-Labels "breaking-change"`, `-IssueAssignees "gewarren"`). +- `-CcMentions` is built from the **runtime PR's** assignee list (Step 1 metadata), + prefixing each GitHub username with `@` and separating with spaces. These are + used for the `/cc` notification line. The script: -- URL-encodes the title, body, and labels using `[Uri]::EscapeDataString` -- Builds a clickable `https://github.com/dotnet/docs/issues/new?...` link -- Writes a **brief** `pr-comment.md` with instructions, the link, an email - reminder, and `/cc` mentions of the assignees -- Warns if the URL exceeds browser length limits - -The comment intentionally does **not** duplicate the full issue draft. The draft -is available in the `issue-draft.md` workflow artifact and is pre-filled in the -issue creation link. +- Builds a clickable `https://github.com/dotnet/docs/issues/new?...` link that opens + a **blank markdown editor** (not the structured form) with the title, labels, and + assignee pre-filled. +- Uses a **hybrid** body strategy: if the full URL (including the URL-encoded body) + stays within a conservative ~8000-byte budget, the body is pre-filled too so the + issue is one click away. Otherwise the body is dropped from the URL and embedded in + the comment inside a `
` block for the user to copy-paste into the opened + editor. +- Warns if the comment exceeds GitHub's 65536-character comment limit. ### Post the comment @@ -275,14 +320,8 @@ outputs for review. ### AI-generated content disclosure The `Build-IssueComment.ps1` script includes the standard AI disclosure note -in the generated comment. No additional action is needed. - -### Email reminder - -The generated comment includes an email reminder. When running interactively, -also remind the user: -> Please email a link to this breaking change issue to -> [.NET Breaking Change Notifications](mailto:dotnetbcn@microsoft.com). +and the email reminder to the .NET Breaking Change Notifications alias in the +generated comment. No additional action is needed. --- @@ -315,7 +354,7 @@ If the user provides a GitHub search query or asks to process multiple PRs: | Problem | Solution | |---|---| | No `area-*` label on PR | Ask the user to add one, or ask which area applies | -| Version detection script fails | Read the PR's base branch and use `git describe --tags --abbrev=0` manually | +| Version detection script fails | Inspect release branches manually: `gh api repos/dotnet/runtime/branches --paginate --jq '.[].name'` and find the highest `release/.-preview`; a change on `main` ships in Preview N+1 | | PR not yet merged | Breaking change docs are for merged PRs only — inform the user | | Existing docs issue found | Report the existing issue URL and stop | | Cannot determine affected APIs | Review the diff carefully; list the public types/methods in changed files | From 897bec90a321b5901493703585239a3662750eab Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Wed, 8 Jul 2026 14:43:34 +0200 Subject: [PATCH 02/10] Small improvements --- .github/skills/breaking-change-doc/Get-VersionInfo.ps1 | 2 +- .github/skills/breaking-change-doc/SKILL.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index 1ad0739c5dbf95..315c3eeda6f507 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -141,7 +141,7 @@ function Get-NextMilestoneVersion { return [pscustomobject]@{ Version = $version HighestBranch = $highest.Branch - Detection = "Merged to '$baseRef'; ships after highest branched milestone '$($highest.Branch)' (assumes $previewCount previews + $rcCount RCs before GA)" + Detection = "Merged to '$baseRef'; ships after highest branched milestone '$($highest.Branch)'" } } diff --git a/.github/skills/breaking-change-doc/SKILL.md b/.github/skills/breaking-change-doc/SKILL.md index 1f167893781a78..3b81bf627b0a65 100644 --- a/.github/skills/breaking-change-doc/SKILL.md +++ b/.github/skills/breaking-change-doc/SKILL.md @@ -123,7 +123,7 @@ persistent release **tags** to recover the exact first preview/RC. The JSON outp includes: - `EstimatedVersion` — human-readable version string like ".NET 11 Preview 7" - `Tentative` — `true` when the version depends on an unverified linked release-branch - PR (open or merged); treat the version as provisional and say so in the draft + PR (open or merged) - `DetectionMethod` — how the version was determined (direct release-branch merge, own-commit containment in a branched milestone, tag-refined first-shipped version, potential linked backport, or "ships after highest branched milestone") @@ -158,6 +158,8 @@ workflow log. Then check the JSON: - If `Tentative` is `true`, confirm the referenced linked PR is a genuine backport of this change (title/description/diff). If it is, use `EstimatedVersion` and note in the draft that it depends on that backport; if it is not, use `FallbackVersion` instead. +- If the candidate backport is confirmed and merged, treat the `EstimatedVersion` as definitive. + If it's confirmed and still open, treat the `EstimatedVersion` as provisional and note this in the draft. - If the JSON does **not** contain an `Error` field, use `EstimatedVersion` as the version for the breaking change issue. **Do not fall back to manual detection when the script succeeds.** From c9885aa091dd019a3e4163dfc5a44a1bd2a2dd74 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Wed, 8 Jul 2026 15:46:33 +0200 Subject: [PATCH 03/10] Use binary search in Get-EarliestContainingTag The tag-refinement path probed the compare API once per candidate tag, linearly, until it found containment -- up to ~27 calls for dotnet/runtime and rate-limit-prone in CI. Since containment is monotonic along release order (each tag on a major.minor line is a superset of the previous), replace the linear scan with a binary search over the version-ordered candidates, bounding it to ~log2(n) compare calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../breaking-change-doc/Get-VersionInfo.ps1 | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index 315c3eeda6f507..50d6f08b8e1374 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -199,6 +199,13 @@ function Get-TagSortKey { # Find the earliest published tag for a given major.minor that already contains the # merge commit. Used to recover exact preview/RC granularity when branch containment # only resolved to a (pruned) GA line. Returns the parsed tag, or $null. +# +# Containment is monotonic along release order: each tag on a major.minor line is a +# superset of the previous one (preview N+1 includes preview N, GA includes all +# previews/RCs, each servicing release includes GA). So the candidates -- sorted into +# chronological release order by Get-TagSortKey -- form a false*..true* sequence, and +# the earliest containing tag can be found with a binary search (~log2(n) compare-API +# calls) instead of probing every candidate linearly. function Get-EarliestContainingTag { param([string]$repo, [string]$commit, [int]$major, [int]$minor) @@ -213,13 +220,21 @@ function Get-EarliestContainingTag { Where-Object { $_ -and $_.Major -eq $major -and $_.Minor -eq $minor } | Sort-Object { Get-TagSortKey $_ } ) - - foreach ($t in $candidates) { - if (Test-CommitInBranch -repo $repo -commit $commit -branch $t.Tag) { - return $t + if ($candidates.Count -eq 0) { return $null } + + $lo = 0 + $hi = $candidates.Count - 1 + $earliest = $null + while ($lo -le $hi) { + $mid = [int](($lo + $hi) / 2) + if (Test-CommitInBranch -repo $repo -commit $commit -branch $candidates[$mid].Tag) { + $earliest = $candidates[$mid] + $hi = $mid - 1 # a containing tag exists; look for an earlier one + } else { + $lo = $mid + 1 } } - return $null + return $earliest } # Read MajorVersion/MinorVersion from eng/Versions.props at a specific ref. From 3b452514f9289d49234dac4a89b9a7d92165bb15 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Wed, 8 Jul 2026 15:54:04 +0200 Subject: [PATCH 04/10] Harden release-branch parsing and timeline failure handling ConvertFrom-ReleaseBranch: return $null for unrecognized release/* labels (e.g. release/11.0-rtm) instead of a bogus Rank=0 milestone that would sort first in the containment scan and mis-detect the version. Get-BackportPRs: check $LASTEXITCODE on the cross-reference timeline call and, on failure, warn and return no candidates instead of failing silently. Backport detection only enriches the result, so a timeline hiccup shouldn't abort version detection, but the warning surfaces the gap in the log. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../breaking-change-doc/Get-VersionInfo.ps1 | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index 50d6f08b8e1374..c41ee647316ed0 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -57,14 +57,12 @@ function ConvertFrom-ReleaseBranch { $number = if ($matches[4]) { [int]$matches[4] } else { $null } # Milestone ordering rank within a major.minor: previews < rc < GA. - $kind = "ga" - $rank = 3000 switch ($label) { "preview" { $kind = "preview"; $rank = 1000 + ($number ?? 0) } "rc" { $kind = "rc"; $rank = 2000 + ($number ?? 0) } "staging" { $kind = "ga"; $rank = 3000 } $null { $kind = "ga"; $rank = 3000 } - default { $kind = $label; $rank = 0 } + default { return $null } # unrecognized label (e.g. release/11.0-rtm) } return [pscustomobject]@{ @@ -289,11 +287,17 @@ function Get-BackportPRs { # Cross-references can point at PRs in *other* repos (e.g. VMR codeflow PRs from # dotnet/dotnet). Filter to same-repo PRs up front (via source.issue.repository) # so the GraphQL batch only asks for numbers that exist in this repo. - $candidateNumbers = @( - gh api "repos/$repo/issues/$prNumber/timeline" --paginate ` - --jq ".[] | select(.event==`"cross-referenced`") | select(.source.issue.pull_request != null) | select(.source.issue.repository.full_name == `"$repo`") | .source.issue.number" 2>$null | - Sort-Object -Unique - ) + # Capture the call separately and check the exit code: on failure, warn and return + # no candidates rather than failing silently. Backport detection only enriches the + # result (it can make a version tentative), so a timeline hiccup shouldn't abort the + # whole version detection -- but the warning ensures the gap is visible in the log. + $timeline = gh api "repos/$repo/issues/$prNumber/timeline" --paginate ` + --jq ".[] | select(.event==`"cross-referenced`") | select(.source.issue.pull_request != null) | select(.source.issue.repository.full_name == `"$repo`") | .source.issue.number" 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Warning "Failed to read cross-references from the issue timeline for $repo#$prNumber; proceeding without backport candidates." + return @() + } + $candidateNumbers = @($timeline | Sort-Object -Unique) if ($candidateNumbers.Count -eq 0) { return @() } $parts = $repo -split '/' From 91f57e4931f50abc83b23393e9e4ffca8dc08d91 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Wed, 8 Jul 2026 16:59:51 +0200 Subject: [PATCH 05/10] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/skills/breaking-change-doc/Get-VersionInfo.ps1 | 5 ++++- .github/skills/breaking-change-doc/SKILL.md | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index c41ee647316ed0..f8393f431c0e45 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -311,7 +311,10 @@ function Get-BackportPRs { $query = 'query($owner:String!,$name:String!){ repository(owner:$owner,name:$name){ ' + ($aliases -join ' ') + ' } }' $resp = gh api graphql -f owner=$owner -f name=$name -f query=$query 2>$null - if ($LASTEXITCODE -ne 0 -or -not $resp) { return @() } + if ($LASTEXITCODE -ne 0 -or -not $resp) { + Write-Warning "Failed to resolve backport PR metadata via GraphQL for $repo#$prNumber; proceeding without backport candidates." + return @() + } $repository = ($resp | ConvertFrom-Json).data.repository if (-not $repository) { return @() } diff --git a/.github/skills/breaking-change-doc/SKILL.md b/.github/skills/breaking-change-doc/SKILL.md index 3b81bf627b0a65..bb4889d5d61e4c 100644 --- a/.github/skills/breaking-change-doc/SKILL.md +++ b/.github/skills/breaking-change-doc/SKILL.md @@ -356,7 +356,7 @@ If the user provides a GitHub search query or asks to process multiple PRs: | Problem | Solution | |---|---| | No `area-*` label on PR | Ask the user to add one, or ask which area applies | -| Version detection script fails | Inspect release branches manually: `gh api repos/dotnet/runtime/branches --paginate --jq '.[].name'` and find the highest `release/.-preview`; a change on `main` ships in Preview N+1 | +| Version detection script fails | Inspect release branches manually: `gh api repos/dotnet/runtime/branches --paginate --jq '.[].name'` and find the highest milestone branch (`release/.-preview`, `release/.-rc`, or `release/.`); a change on `main` ships in the **next** milestone (Preview N+1, or RC 1 after the final Preview, or GA after the final RC) | | PR not yet merged | Breaking change docs are for merged PRs only — inform the user | | Existing docs issue found | Report the existing issue URL and stop | | Cannot determine affected APIs | Review the diff carefully; list the public types/methods in changed files | From 2e7070de354233b75b962dcecd73a6cf45397260 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Wed, 8 Jul 2026 17:26:40 +0200 Subject: [PATCH 06/10] Fetch only target major.minor tags via git/matching-refs Get-EarliestContainingTag paginated every tag in the repo (~195 for dotnet/runtime) and filtered in memory. Query only the relevant tags server-side via git/matching-refs/tags/v.. and strip the refs/tags/ prefix, cutting the payload to the ~27 tags for that release line. The endpoint returns 200 with an empty array (not 404) when nothing matches, so the existing exit-code guard still holds; the candidate set and binary search are otherwise unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/breaking-change-doc/Get-VersionInfo.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index f8393f431c0e45..e7c66b34e88929 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -196,7 +196,8 @@ function Get-TagSortKey { # Find the earliest published tag for a given major.minor that already contains the # merge commit. Used to recover exact preview/RC granularity when branch containment -# only resolved to a (pruned) GA line. Returns the parsed tag, or $null. +# only resolved to a (pruned) GA line. Only the tags for that major.minor are fetched +# (server-side prefix filter). Returns the parsed tag, or $null. # # Containment is monotonic along release order: each tag on a major.minor line is a # superset of the previous one (preview N+1 includes preview N, GA includes all @@ -209,12 +210,17 @@ function Get-EarliestContainingTag { if (-not $commit) { return $null } - $tagNames = @(gh api "repos/$repo/tags" --paginate --jq '.[].name' 2>$null) + # Fetch only the tags for this major.minor via a server-side prefix filter + # (git/matching-refs) instead of paginating every tag in the repo. The trailing + # dot scopes the match to v..* (e.g. v9.0.0, v9.0.0-preview.4, + # v9.0.10). This endpoint returns HTTP 200 with an empty array (not 404) when + # nothing matches, so the exit-code check below stays valid. + $refs = @(gh api "repos/$repo/git/matching-refs/tags/v$major.$minor." --paginate --jq '.[].ref' 2>$null) if ($LASTEXITCODE -ne 0) { return $null } $candidates = @( - $tagNames | - ForEach-Object { ConvertFrom-DotNetTag $_ } | + $refs | + ForEach-Object { ConvertFrom-DotNetTag ($_ -replace '^refs/tags/', '') } | Where-Object { $_ -and $_.Major -eq $major -and $_.Minor -eq $minor } | Sort-Object { Get-TagSortKey $_ } ) From 966db2f522deeaafc53e1ba2b48d96b3e9bf82ce Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Fri, 10 Jul 2026 11:49:57 +0200 Subject: [PATCH 07/10] Roll GA over to next major in next-milestone prediction Get-NextMilestoneVersion returned the GA version itself when the highest branched milestone was GA, so a change on main that is not contained in a fully-shipped release line was reported as that same GA version instead of the next major's first preview. This surfaced as a wrong FallbackVersion (e.g. .NET 10 instead of .NET 11 Preview 1 for a change merged during the release/N fork-to-rename window). Roll the GA case over to (major+1).0 Preview 1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/breaking-change-doc/Get-VersionInfo.ps1 | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index e7c66b34e88929..4b7947bbe8125f 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -133,7 +133,11 @@ function Get-NextMilestoneVersion { Format-Milestone $devVersion.Major $devVersion.Minor "ga" $null } } - default { $version = Format-Milestone $devVersion.Major $devVersion.Minor "ga" $null } + default { + # Highest branched milestone is GA: + # a change on main that isn't in it ships in the *next* major's first preview. + $version = Format-Milestone ($devVersion.Major + 1) 0 "preview" 1 + } } return [pscustomobject]@{ From 95e21a0f7897546db5c9cfebc7428c6b36b75eee Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Mon, 13 Jul 2026 18:33:42 +0200 Subject: [PATCH 08/10] Refine bare pre-GA release/N.M to the RC stage it is heading toward A bare release/N.M branch is cut around the RC1 fork -- months before GA (verified: release/10.0 existed from 2025-08-16, ~3 months before GA) -- yet is parsed as a GA milestone by name, so a PR merged directly into it (Step 2) or a backport targeting it (Step 5b) was reported as the coarse ".NET N" instead of an RC stage. Add Resolve-ReleaseBranchStage: a separate release/N.M-rc branch exists for each RC that has been taken (these stay active and can receive fixes), while the bare branch keeps stabilizing toward the next stage. When the product has not GA'd (no vN.M.0 tag), map the highest existing rc branch number K to the bare branch's stage -- no rc branch -> RC 1, rc -> RC (K+1), final RC branch exists -> GA -- and apply it in both the direct-merge and backport paths. The main-PR next-milestone prediction still treats the bare branch as GA so it can roll over to the next major. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../breaking-change-doc/Get-VersionInfo.ps1 | 79 ++++++++++++++++++- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 index 4b7947bbe8125f..9116c56dbe5cbc 100644 --- a/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 +++ b/.github/skills/breaking-change-doc/Get-VersionInfo.ps1 @@ -3,7 +3,7 @@ # # The reliable signal is the existence of release branches named # release/.-preview (and later -rc / GA release/.) -# Once release/.-preview exists, that preview is branched and locked, so a +# Once release/.-preview exists, that preview has been branched off, so a # change merged to main lands in the *next* milestone -- following the standard .NET # cadence of $PreviewCount previews, then $RcCount RCs, then GA (so Preview 7 rolls # over to RC 1, and RC 2 to GA). Exceptions: @@ -245,6 +245,65 @@ function Get-EarliestContainingTag { return $earliest } +# Has major.minor reached GA? True when the exact GA tag vN.M.0 exists (ignoring +# prerelease tags like vN.M.0-preview/-rc). Used to tell a *pre-GA* release/N.M branch +# -- which is cut at the RC1 fork, months before GA, and parsed as a GA milestone by +# name -- apart from a shipped GA/servicing branch. +function Test-GATagExists { + param([string]$repo, [int]$major, [int]$minor) + + $refs = @(gh api "repos/$repo/git/matching-refs/tags/v$major.$minor.0" --jq '.[].ref' 2>$null) + if ($LASTEXITCODE -ne 0) { return $false } + foreach ($r in $refs) { + if (($r -replace '^refs/tags/', '') -eq "v$major.$minor.0") { return $true } + } + return $false +} + +# Refine a milestone parsed from a bare `release/N.M` branch to the stage it is heading +# toward. A bare release/N.M branch is cut around the RC1 fork -- months before GA -- +# yet is parsed as a GA milestone by name. A separate `release/N.M-rc` branch exists +# for each RC that has been taken; the bare branch keeps stabilizing toward the *next* stage. +# So the highest rc branch number present indicates how far along the bare branch is: +# if the product has not GA'd (no vN.M.0 tag), map the highest existing rc branch number K to the bare +# branch's stage: +# no rc branch yet -> RC 1 (just forked); rc exists -> RC (K+1); the final RC +# (rcCount) branch exists -> heading to GA. Returns the milestone unchanged when it +# already GA'd, isn't a GA-kind milestone, or the rc lookup fails. +function Resolve-ReleaseBranchStage { + param([string]$repo, [pscustomobject]$milestone, [int]$rcCount = 2) + + if (-not $milestone -or $milestone.Kind -ne 'ga') { return $milestone } + if (Test-GATagExists -repo $repo -major $milestone.Major -minor $milestone.Minor) { return $milestone } + + $rcRefs = @(gh api "repos/$repo/git/matching-refs/heads/release/$($milestone.Major).$($milestone.Minor)-rc" --jq '.[].ref' 2>$null) + if ($LASTEXITCODE -ne 0) { return $milestone } + + $highestRc = @( + $rcRefs | + ForEach-Object { ConvertFrom-ReleaseBranch ($_ -replace '^refs/heads/', '') } | + Where-Object { $_ -and $_.Kind -eq 'rc' -and $_.Major -eq $milestone.Major -and $_.Minor -eq $milestone.Minor } | + Sort-Object Rank -Descending | + Select-Object -First 1 + ) + + $k = if ($highestRc) { $highestRc.Number } else { 0 } + if ($k -ge $rcCount) { + # The final RC branch already exists -> the bare branch heads to GA (unchanged). + return $milestone + } + + $next = $k + 1 + return [pscustomobject]@{ + Major = $milestone.Major + Minor = $milestone.Minor + Kind = 'rc' + Number = $next + Rank = 2000 + $next + Branch = $milestone.Branch + } +} + # Read MajorVersion/MinorVersion from eng/Versions.props at a specific ref. # This is the authoritative source for the in-development major.minor on main. function Get-DevMajorMinor { @@ -363,10 +422,12 @@ try { } # Step 2: If the PR merged directly into a release branch, the milestone is that - # branch's milestone -- no further estimation needed. + # branch's milestone -- no further estimation needed. Refine a bare pre-GA + # release/N.M to its current RC stage (see Resolve-ReleaseBranchStage). $baseMilestone = if ($BaseRef -match '^release/') { ConvertFrom-ReleaseBranch $BaseRef } else { $null } if ($baseMilestone) { + $baseMilestone = Resolve-ReleaseBranchStage -repo $SourceRepo -milestone $baseMilestone -rcCount $RcCount $estimated = Format-Milestone $baseMilestone.Major $baseMilestone.Minor $baseMilestone.Kind $baseMilestone.Number @{ EstimatedVersion = $estimated @@ -472,7 +533,17 @@ try { Select-Object -First 1 if ($tentativeBackport) { - $bm = $tentativeBackport.Milestone + # RC-precision refinement: the target may be a bare pre-GA release/N.M branch + # (GA milestone by name but really heading to an RC stage). Resolve it to the + # stage it is heading toward. This only refines the reported backport version; + # the main-PR next-milestone prediction below still treats the bare branch as GA + # so it can roll over to the next major. + $origMilestone = $tentativeBackport.Milestone + $bm = Resolve-ReleaseBranchStage -repo $SourceRepo -milestone $origMilestone -rcCount $RcCount + $refinedNote = if ($bm.Kind -ne $origMilestone.Kind -or $bm.Number -ne $origMilestone.Number) { + " '$($tentativeBackport.TargetBranch)' is a pre-GA branch heading to the $(Format-Milestone $bm.Major $bm.Minor $bm.Kind $bm.Number) stage." + } else { "" } + $estimated = Format-Milestone $bm.Major $bm.Minor $bm.Kind $bm.Number $stateNote = if ($tentativeBackport.State -eq 'MERGED') { "merged into '$($tentativeBackport.TargetBranch)' (may already ship there)" @@ -488,7 +559,7 @@ try { @{ EstimatedVersion = $estimated Tentative = $true - DetectionMethod = "Potential backport PR #$($tentativeBackport.Number) $stateNote; version is tentative -- confirm the PR is a genuine backport of this change (title/description/diff). If it is NOT, use FallbackVersion instead." + DetectionMethod = "Potential backport PR #$($tentativeBackport.Number) $stateNote;$refinedNote version is tentative -- confirm the PR is a genuine backport of this change (title/description/diff). If it is NOT, use FallbackVersion instead." FallbackVersion = $fallback.Version HighestBranch = $fallback.HighestBranch Backports = $backportInfo From 39096e3ad1e8dd18aca03f3070665be9dcaf7c34 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Mon, 13 Jul 2026 19:01:51 +0200 Subject: [PATCH 09/10] Truncate embedded body when the PR comment would exceed GitHub's limit The hybrid link strategy embeds the issue body in the comment when it is too long for the URL, but this was warn-only: an extreme body could push the comment past GitHub's 65536-char limit and the later post would fail. Refactor the comment assembly into a reusable template and, when the full body overflows the comment budget, truncate the embedded copy (with a marker and an [!IMPORTANT] note pointing at the complete issue-draft.md artifact) so the post still succeeds. Add a -MaxCommentLength parameter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Build-IssueComment.ps1 | 58 +++++++++++++------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/.github/skills/breaking-change-doc/Build-IssueComment.ps1 b/.github/skills/breaking-change-doc/Build-IssueComment.ps1 index 4e688e4bc0e318..ef6f29cf0c865d 100644 --- a/.github/skills/breaking-change-doc/Build-IssueComment.ps1 +++ b/.github/skills/breaking-change-doc/Build-IssueComment.ps1 @@ -51,7 +51,10 @@ param( # Conservative budget on the full URL string length in bytes. GitHub's front end # rejects a request line over ~8192 bytes with HTTP 414; 8000 leaves headroom. - [int]$MaxUrlBytes = 8000 + [int]$MaxUrlBytes = 8000, + + # GitHub rejects comment bodies over 65536 characters; 65000 leaves headroom. + [int]$MaxCommentLength = 65000 ) $ErrorActionPreference = "Stop" @@ -93,33 +96,44 @@ if (-not [string]::IsNullOrWhiteSpace($CcMentions)) { $mentionLine = "`n`n/cc $($CcMentions.Trim())" } -# When the body isn't pre-filled, include it in the comment for copy-paste. -# The body is wrapped in a fenced code block so the raw markdown can be copied -# verbatim. A four-backtick fence is used because the body itself contains -# three-backtick code fences. (Built from a single-quoted variable so PowerShell's -# backtick escaping doesn't collapse it.) +# When the body isn't pre-filled in the URL it is embedded in the comment for +# copy-paste, wrapped in a fenced code block so the raw markdown copies verbatim. A +# four-backtick fence is used because the body itself contains three-backtick code +# fences. (Built from a single-quoted variable so PowerShell's backtick escaping +# doesn't collapse it.) $fence = '````' -$copyPasteSection = "" -if (-not $bodyInUrl) { - $copyPasteSection = @" + +# Assemble the comment. $bodyText is what goes inside the copy-paste fence; when +# $truncated is set, a note points the reader at the complete issue-draft.md artifact +# (used when the full body had to be shortened to fit GitHub's comment-length limit). +$assemble = { + param([string]$bodyText, [bool]$truncated) + + $copyPasteSection = "" + if (-not $bodyInUrl) { + $truncNote = if ($truncated) { + "`n`n> [!IMPORTANT]`n> The body below was truncated to fit GitHub's comment-length limit. Copy the complete body from the issue-draft.md workflow artifact instead." + } else { "" } + + $copyPasteSection = @" The issue body is too long to pre-fill in the link above, so the link opens a **blank issue** with only the title, labels, and assignees set. Copy the body -below into the description field before submitting. +below into the description field before submitting.$truncNote
Issue body (click to expand, then copy) $($fence)md -$issueBody +$bodyText $fence
"@ -} + } -$comment = @" + return @" ## Breaking Change Documentation A breaking change draft has been prepared for this PR. @@ -132,11 +146,21 @@ After creating the issue, please email a link to it to the > [!NOTE] > This documentation was generated with AI assistance from Copilot. "@ +} -# GitHub comment body limit is 65536 characters. -$maxCommentLength = 65000 -if ($comment.Length -gt $maxCommentLength) { - Write-Warning "Comment body ($($comment.Length) chars) exceeds GitHub's comment length limit. The issue body may be too large to embed." +$comment = & $assemble $issueBody $false + +# If embedding the full body pushes the comment past GitHub's limit, truncate the +# embedded copy so the later post still succeeds. The complete body is always available +# in issue-draft.md, which the truncation note points to. +if (-not $bodyInUrl -and $comment.Length -gt $MaxCommentLength) { + $marker = "`n`n[... body truncated -- see the issue-draft.md artifact for the full text ...]" + $overhead = (& $assemble '' $true).Length + $available = $MaxCommentLength - $overhead - $marker.Length - 50 # 50-char safety margin + if ($available -lt 0) { $available = 0 } + $truncatedBody = $issueBody.Substring(0, [Math]::Min($issueBody.Length, $available)).TrimEnd() + $marker + $comment = & $assemble $truncatedBody $true + Write-Warning "Issue body too large to embed; truncated the copy-paste body to fit GitHub's comment-length limit. The full body is in the issue-draft.md artifact." } $comment | Out-File -FilePath $OutputPath -Encoding UTF8 -NoNewline From 2ac28a6832423ea67bfee10e2e6d53075890cdd1 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Mon, 13 Jul 2026 19:25:26 +0200 Subject: [PATCH 10/10] Compute copy-paste fence length from the body's backtick runs The embedded copy-paste block used a fixed four-backtick fence, which assumed bodies only ever contain three-backtick code fences; a body with a >=4-backtick fence would break the
block. Compute the fence as one backtick longer than the longest backtick run in the body (minimum 3) so a code fence of any length can't prematurely close it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../breaking-change-doc/Build-IssueComment.ps1 | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/skills/breaking-change-doc/Build-IssueComment.ps1 b/.github/skills/breaking-change-doc/Build-IssueComment.ps1 index ef6f29cf0c865d..caa7eab2317769 100644 --- a/.github/skills/breaking-change-doc/Build-IssueComment.ps1 +++ b/.github/skills/breaking-change-doc/Build-IssueComment.ps1 @@ -97,11 +97,14 @@ if (-not [string]::IsNullOrWhiteSpace($CcMentions)) { } # When the body isn't pre-filled in the URL it is embedded in the comment for -# copy-paste, wrapped in a fenced code block so the raw markdown copies verbatim. A -# four-backtick fence is used because the body itself contains three-backtick code -# fences. (Built from a single-quoted variable so PowerShell's backtick escaping -# doesn't collapse it.) -$fence = '````' +# copy-paste, wrapped in a fenced code block so the raw markdown copies verbatim. Use a +# fence one backtick longer than the longest backtick run in the body (minimum 3), so an +# embedded code fence of any length can't prematurely close the block. +$longestBacktickRun = 0 +foreach ($m in [regex]::Matches($issueBody, '`+')) { + if ($m.Length -gt $longestBacktickRun) { $longestBacktickRun = $m.Length } +} +$fence = '`' * [Math]::Max(3, $longestBacktickRun + 1) # Assemble the comment. $bodyText is what goes inside the copy-paste fence; when # $truncated is set, a note points the reader at the complete issue-draft.md artifact