|
| 1 | +#Requires -Version 7.0 |
| 2 | +#Requires -PSEdition Core |
| 3 | + |
| 4 | +# Copyright (c) Microsoft Corporation. All rights reserved. |
| 5 | +# Licensed under the MIT License. |
| 6 | + |
| 7 | +<# |
| 8 | +.SYNOPSIS |
| 9 | +[Experimental] Automates creation of a patch-release mergeback against the current branch. |
| 10 | +
|
| 11 | +.DESCRIPTION |
| 12 | +[Experimental] Given a release branch (e.g. release/patch/20260505) that contains a patch |
| 13 | +release, this script ports the patch's published-version bumps and changelog |
| 14 | +entries back into the current working branch. It performs three steps: |
| 15 | +
|
| 16 | + 1. version_client.txt: For every line whose dependency-version (middle |
| 17 | + column) was bumped on the release branch, copy that bumped value into |
| 18 | + the current branch's version_client.txt. The current-version (right |
| 19 | + column) on the current branch is preserved as-is, because it usually |
| 20 | + reflects in-development beta versions that should not be reverted. |
| 21 | +
|
| 22 | + 2. CHANGELOG.md: For each library whose dependency-version changed, take |
| 23 | + the topmost (newest) entry from the release branch's CHANGELOG and |
| 24 | + insert it into the current branch's CHANGELOG. When an existing |
| 25 | + "## ... (Unreleased)" section is present, the new entry is inserted |
| 26 | + after that entire section (before the next "##" heading). Otherwise, |
| 27 | + it is inserted before the first dated entry (immediately after the |
| 28 | + "# Release History" heading). |
| 29 | +
|
| 30 | + 3. Runs `python eng/versioning/update_versions.py --skip-readme` to |
| 31 | + propagate the new dependency-versions into all pom.xml files. |
| 32 | +
|
| 33 | +This script does NOT commit, push, or open a pull request. Review the |
| 34 | +working tree afterwards, then commit and push manually. |
| 35 | +
|
| 36 | +.PARAMETER ReleaseBranch |
| 37 | +The name of the patch release branch to port edits from |
| 38 | +(for example release/patch/20260505). The branch can be local or remote; |
| 39 | +the script will use `git fetch` and resolve origin/<branch> if needed. |
| 40 | +
|
| 41 | +.PARAMETER RepoRoot |
| 42 | +Optional. Path to the azure-sdk-for-java clone. Defaults to the repo root |
| 43 | +inferred from the script's location. |
| 44 | +
|
| 45 | +.EXAMPLE |
| 46 | +.\eng\scripts\Create-Patch-Mergeback.ps1 -ReleaseBranch release/patch/20260505 |
| 47 | +#> |
| 48 | + |
| 49 | +[CmdletBinding()] |
| 50 | +param( |
| 51 | + [Parameter(Mandatory = $true)] |
| 52 | + [string]$ReleaseBranch, |
| 53 | + |
| 54 | + [string]$RepoRoot |
| 55 | +) |
| 56 | + |
| 57 | +$ErrorActionPreference = 'Stop' |
| 58 | + |
| 59 | +# Resolve repo root. |
| 60 | +if (-not $RepoRoot) { |
| 61 | + $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') |
| 62 | +} |
| 63 | +$RepoRoot = (Resolve-Path $RepoRoot).Path |
| 64 | +Push-Location $RepoRoot |
| 65 | +try { |
| 66 | + Write-Host "Repo root: $RepoRoot" |
| 67 | + |
| 68 | + $workingTreeStatus = git status --porcelain |
| 69 | + if ($LASTEXITCODE -ne 0) { |
| 70 | + throw "Failed to check working tree status." |
| 71 | + } |
| 72 | + if ($workingTreeStatus) { |
| 73 | + throw "Working tree is not clean. Commit, stash, or discard local changes before running this script." |
| 74 | + } |
| 75 | + |
| 76 | + # --------------------------------------------------------------------- |
| 77 | + # Resolve the release branch ref. |
| 78 | + # --------------------------------------------------------------------- |
| 79 | + Write-Host "Fetching latest refs..." |
| 80 | + git fetch --quiet origin 2>$null | Out-Null |
| 81 | + |
| 82 | + $branchRef = $null |
| 83 | + foreach ($candidate in @($ReleaseBranch, "origin/$ReleaseBranch")) { |
| 84 | + $null = git rev-parse --verify --quiet "$candidate" 2>$null |
| 85 | + if ($LASTEXITCODE -eq 0) { $branchRef = $candidate; break } |
| 86 | + } |
| 87 | + if (-not $branchRef) { |
| 88 | + throw "Could not resolve '$ReleaseBranch' (tried local and origin/)." |
| 89 | + } |
| 90 | + Write-Host "Using release branch ref: $branchRef" |
| 91 | + |
| 92 | + # --------------------------------------------------------------------- |
| 93 | + # 1. version_client.txt: port dependency-version bumps. |
| 94 | + # --------------------------------------------------------------------- |
| 95 | + $vcRelPath = 'eng/versioning/version_client.txt' |
| 96 | + $vcPath = Join-Path $RepoRoot $vcRelPath |
| 97 | + |
| 98 | + Write-Host "`n[1/3] Porting dependency-version bumps from $vcRelPath ..." |
| 99 | + |
| 100 | + $releaseVcRaw = git show "${branchRef}:${vcRelPath}" |
| 101 | + if ($LASTEXITCODE -ne 0) { throw "Failed to read $vcRelPath from $branchRef." } |
| 102 | + $releaseLines = $releaseVcRaw -split "`r?`n" |
| 103 | + $localLines = Get-Content -LiteralPath $vcPath |
| 104 | + |
| 105 | + function Parse-VcLine([string]$line) { |
| 106 | + # Returns @{ Key=...; Dep=...; Cur=...; Comment=... } or $null |
| 107 | + if ([string]::IsNullOrWhiteSpace($line)) { return $null } |
| 108 | + $trim = $line.TrimStart() |
| 109 | + if ($trim.StartsWith('#')) { return $null } |
| 110 | + # Strip optional inline comment. |
| 111 | + $body = $line |
| 112 | + $note = '' |
| 113 | + $hash = $line.IndexOf(' #') |
| 114 | + if ($hash -ge 0) { |
| 115 | + $body = $line.Substring(0, $hash) |
| 116 | + $note = $line.Substring($hash) |
| 117 | + } |
| 118 | + $parts = $body.Split(';') |
| 119 | + if ($parts.Count -lt 3) { return $null } |
| 120 | + return [pscustomobject]@{ |
| 121 | + Key = $parts[0].Trim() |
| 122 | + Dep = $parts[1].Trim() |
| 123 | + Cur = $parts[2].Trim() |
| 124 | + Comment = $note |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + $releaseMap = @{} |
| 129 | + foreach ($l in $releaseLines) { |
| 130 | + $p = Parse-VcLine $l |
| 131 | + if ($p) { $releaseMap[$p.Key] = $p } |
| 132 | + } |
| 133 | + |
| 134 | + $changedArtifacts = New-Object System.Collections.Generic.List[string] |
| 135 | + $newLocal = New-Object System.Collections.Generic.List[string] |
| 136 | + |
| 137 | + foreach ($line in $localLines) { |
| 138 | + $p = Parse-VcLine $line |
| 139 | + if (-not $p -or -not $releaseMap.ContainsKey($p.Key)) { |
| 140 | + $newLocal.Add($line); continue |
| 141 | + } |
| 142 | + $r = $releaseMap[$p.Key] |
| 143 | + if ($r.Dep -eq $p.Dep) { |
| 144 | + $newLocal.Add($line); continue |
| 145 | + } |
| 146 | + # Bump dep-version, keep local current-version. |
| 147 | + $newBody = "$($p.Key);$($r.Dep);$($p.Cur)" |
| 148 | + $newLocal.Add("$newBody$($p.Comment)") |
| 149 | + $changedArtifacts.Add($p.Key) | Out-Null |
| 150 | + } |
| 151 | + |
| 152 | + if ($changedArtifacts.Count -eq 0) { |
| 153 | + Write-Host " No dependency-version changes found. Nothing to port." |
| 154 | + return |
| 155 | + } |
| 156 | + |
| 157 | + # Preserve original line endings of the file. |
| 158 | + $origBytes = [System.IO.File]::ReadAllBytes($vcPath) |
| 159 | + $useCrlf = $false |
| 160 | + for ($i = 0; $i -lt [Math]::Min($origBytes.Length, 4096); $i++) { |
| 161 | + if ($origBytes[$i] -eq 13) { $useCrlf = $true; break } |
| 162 | + } |
| 163 | + $eol = if ($useCrlf) { "`r`n" } else { "`n" } |
| 164 | + [System.IO.File]::WriteAllText($vcPath, ($newLocal -join $eol) + $eol) |
| 165 | + |
| 166 | + Write-Host " Bumped $($changedArtifacts.Count) artifact(s) in version_client.txt." |
| 167 | + |
| 168 | + # --------------------------------------------------------------------- |
| 169 | + # 2. CHANGELOG.md: port the latest entry from the release branch for |
| 170 | + # each artifact whose dependency-version changed. |
| 171 | + # --------------------------------------------------------------------- |
| 172 | + Write-Host "`n[2/3] Porting CHANGELOG.md entries..." |
| 173 | + |
| 174 | + # Build artifactId -> list of CHANGELOG.md paths once. |
| 175 | + $changelogIndex = @{} |
| 176 | + Get-ChildItem -Path (Join-Path $RepoRoot 'sdk') -Recurse -Filter 'CHANGELOG.md' -File ` |
| 177 | + | ForEach-Object { |
| 178 | + $artifactId = $_.Directory.Name |
| 179 | + if (-not $changelogIndex.ContainsKey($artifactId)) { |
| 180 | + $changelogIndex[$artifactId] = New-Object System.Collections.Generic.List[string] |
| 181 | + } |
| 182 | + $changelogIndex[$artifactId].Add($_.FullName) | Out-Null |
| 183 | + } |
| 184 | + |
| 185 | + function Resolve-ChangelogPath([string]$artifactId) { |
| 186 | + if (-not $changelogIndex.ContainsKey($artifactId)) { return $null } |
| 187 | + $paths = $changelogIndex[$artifactId] |
| 188 | + if ($paths.Count -eq 1) { return $paths[0] } |
| 189 | + # Prefer the v1 (non *-v2) directory, then shortest path as a tiebreaker. |
| 190 | + $nonV2 = $paths | Where-Object { $_ -notmatch '[\\/][^\\/]*-v2[\\/]' } |
| 191 | + if ($nonV2.Count -eq 1) { return $nonV2[0] } |
| 192 | + return ($paths | Sort-Object Length | Select-Object -First 1) |
| 193 | + } |
| 194 | + |
| 195 | + function Get-LatestChangelogEntry([string]$relPath) { |
| 196 | + # Returns @{ Header=...; Body=... } for the topmost ## entry, or $null. |
| 197 | + $raw = git show "${branchRef}:${relPath}" 2>$null |
| 198 | + if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null } |
| 199 | + $lines = $raw -split "`r?`n" |
| 200 | + $startIdx = -1 |
| 201 | + for ($i = 0; $i -lt $lines.Count; $i++) { |
| 202 | + if ($lines[$i] -match '^##\s+\S') { $startIdx = $i; break } |
| 203 | + } |
| 204 | + if ($startIdx -lt 0) { return $null } |
| 205 | + $endIdx = $lines.Count |
| 206 | + for ($i = $startIdx + 1; $i -lt $lines.Count; $i++) { |
| 207 | + if ($lines[$i] -match '^##\s+\S') { $endIdx = $i; break } |
| 208 | + } |
| 209 | + # Trim trailing blank lines. |
| 210 | + $block = $lines[$startIdx..($endIdx - 1)] |
| 211 | + while ($block.Count -gt 0 -and [string]::IsNullOrWhiteSpace($block[-1])) { |
| 212 | + $block = $block[0..($block.Count - 2)] |
| 213 | + } |
| 214 | + return [pscustomobject]@{ |
| 215 | + Header = $lines[$startIdx] |
| 216 | + Body = ($block -join "`n") |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + $portedCount = 0 |
| 221 | + $skippedNoFile = New-Object System.Collections.Generic.List[string] |
| 222 | + $skippedNoEntry = New-Object System.Collections.Generic.List[string] |
| 223 | + $alreadyPresent = New-Object System.Collections.Generic.List[string] |
| 224 | + |
| 225 | + foreach ($key in $changedArtifacts) { |
| 226 | + $artifactId = $key.Split(':')[1] |
| 227 | + $localPath = Resolve-ChangelogPath $artifactId |
| 228 | + if (-not $localPath) { $skippedNoFile.Add($artifactId) | Out-Null; continue } |
| 229 | + |
| 230 | + $relLocalPath = (Resolve-Path -LiteralPath $localPath -Relative).TrimStart('.', '\', '/').Replace('\', '/') |
| 231 | + |
| 232 | + $entry = Get-LatestChangelogEntry $relLocalPath |
| 233 | + if (-not $entry) { $skippedNoEntry.Add($artifactId) | Out-Null; continue } |
| 234 | + |
| 235 | + $existing = Get-Content -LiteralPath $localPath -Raw |
| 236 | + # Skip if the same released-version header is already present. |
| 237 | + $headerEscaped = [regex]::Escape($entry.Header) |
| 238 | + if ($existing -match "(?m)^$headerEscaped\s*$") { |
| 239 | + $alreadyPresent.Add($artifactId) | Out-Null; continue |
| 240 | + } |
| 241 | + |
| 242 | + $existingLines = $existing -split "`r?`n" |
| 243 | + |
| 244 | + # Find an Unreleased heading; if none, find the first dated heading. |
| 245 | + $unreleasedIdx = -1 |
| 246 | + $firstDatedIdx = -1 |
| 247 | + for ($i = 0; $i -lt $existingLines.Count; $i++) { |
| 248 | + if ($unreleasedIdx -lt 0 -and $existingLines[$i] -match '^##\s+.*\(Unreleased\)\s*$') { |
| 249 | + $unreleasedIdx = $i |
| 250 | + } elseif ($firstDatedIdx -lt 0 -and $existingLines[$i] -match '^##\s+\S') { |
| 251 | + $firstDatedIdx = $i |
| 252 | + } |
| 253 | + } |
| 254 | + |
| 255 | + $insertText = $entry.Body.TrimEnd() + "`n" |
| 256 | + |
| 257 | + if ($unreleasedIdx -ge 0) { |
| 258 | + # Find end of the Unreleased block (next ## heading or EOF). |
| 259 | + $blockEnd = $existingLines.Count |
| 260 | + for ($i = $unreleasedIdx + 1; $i -lt $existingLines.Count; $i++) { |
| 261 | + if ($existingLines[$i] -match '^##\s+\S') { $blockEnd = $i; break } |
| 262 | + } |
| 263 | + # Strip trailing blanks inside the unreleased block before inserting. |
| 264 | + $tail = $blockEnd |
| 265 | + while ($tail -gt $unreleasedIdx + 1 -and [string]::IsNullOrWhiteSpace($existingLines[$tail - 1])) { |
| 266 | + $tail-- |
| 267 | + } |
| 268 | + $before = if ($tail -gt 0) { $existingLines[0..($tail - 1)] } else { @() } |
| 269 | + $after = if ($blockEnd -lt $existingLines.Count) { $existingLines[$blockEnd..($existingLines.Count - 1)] } else { @() } |
| 270 | + $merged = @() |
| 271 | + $merged += $before |
| 272 | + $merged += '' |
| 273 | + $merged += ($insertText -split "`n") |
| 274 | + if ($after.Count -gt 0) { $merged += $after } |
| 275 | + } elseif ($firstDatedIdx -ge 0) { |
| 276 | + $before = $existingLines[0..($firstDatedIdx - 1)] |
| 277 | + $after = $existingLines[$firstDatedIdx..($existingLines.Count - 1)] |
| 278 | + # Ensure exactly one blank between. |
| 279 | + while ($before.Count -gt 0 -and [string]::IsNullOrWhiteSpace($before[-1])) { |
| 280 | + $before = $before[0..($before.Count - 2)] |
| 281 | + } |
| 282 | + $merged = @() |
| 283 | + $merged += $before |
| 284 | + $merged += '' |
| 285 | + $merged += ($insertText -split "`n") |
| 286 | + $merged += '' |
| 287 | + $merged += $after |
| 288 | + } else { |
| 289 | + # No headings at all; append. |
| 290 | + $merged = $existingLines + @('') + ($insertText -split "`n") |
| 291 | + } |
| 292 | + |
| 293 | + # Determine line ending of the existing file. |
| 294 | + $crlf = $existing.Contains("`r`n") |
| 295 | + $eol2 = if ($crlf) { "`r`n" } else { "`n" } |
| 296 | + $finalText = ($merged -join $eol2) |
| 297 | + if (-not $finalText.EndsWith($eol2)) { $finalText += $eol2 } |
| 298 | + [System.IO.File]::WriteAllText($localPath, $finalText) |
| 299 | + $portedCount++ |
| 300 | + } |
| 301 | + |
| 302 | + Write-Host " Ported $portedCount changelog entr$(if ($portedCount -eq 1){'y'}else{'ies'})." |
| 303 | + if ($alreadyPresent.Count -gt 0) { |
| 304 | + Write-Host " Already present (skipped): $($alreadyPresent -join ', ')" |
| 305 | + } |
| 306 | + if ($skippedNoFile.Count -gt 0) { |
| 307 | + Write-Warning " No CHANGELOG.md found for: $($skippedNoFile -join ', ')" |
| 308 | + } |
| 309 | + if ($skippedNoEntry.Count -gt 0) { |
| 310 | + Write-Warning " No release entry found on $branchRef for: $($skippedNoEntry -join ', ')" |
| 311 | + } |
| 312 | + |
| 313 | + # --------------------------------------------------------------------- |
| 314 | + # 3. Propagate dependency-versions into pom.xml files. |
| 315 | + # --------------------------------------------------------------------- |
| 316 | + Write-Host "`n[3/3] Running update_versions.py --skip-readme ..." |
| 317 | + $py = (Get-Command python -ErrorAction SilentlyContinue) ?? (Get-Command python3 -ErrorAction SilentlyContinue) |
| 318 | + if (-not $py) { throw "python is not on PATH; cannot run update_versions.py." } |
| 319 | + & $py.Source 'eng/versioning/update_versions.py' '--skip-readme' |
| 320 | + if ($LASTEXITCODE -ne 0) { |
| 321 | + throw "update_versions.py failed with exit code $LASTEXITCODE." |
| 322 | + } |
| 323 | + |
| 324 | + # --------------------------------------------------------------------- |
| 325 | + # Summary. |
| 326 | + # --------------------------------------------------------------------- |
| 327 | + $status = git status --porcelain |
| 328 | + $clCount = ($status | Where-Object { $_ -match 'CHANGELOG\.md' }).Count |
| 329 | + $pomCount = ($status | Where-Object { $_ -match 'pom\.xml' }).Count |
| 330 | + |
| 331 | + Write-Host "`nDone." |
| 332 | + Write-Host " version_client.txt artifacts bumped : $($changedArtifacts.Count)" |
| 333 | + Write-Host " CHANGELOG.md files modified : $clCount" |
| 334 | + Write-Host " pom.xml files modified : $pomCount" |
| 335 | + Write-Host "`nReview with 'git diff', then commit and push when ready." |
| 336 | +} |
| 337 | +finally { |
| 338 | + Pop-Location |
| 339 | +} |
0 commit comments