diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index bafdb4fa6..db7cdf97b 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -776,13 +776,56 @@ function Write-Changelog { $currentDate = (Get-Date).ToString('yyyy-MM-dd') - # Get commits since the latest tag (unreleased commits) + # Get commits since the latest tag (unreleased commits) that touched this package, together + # with the per-commit list of files each one changed within the folder. A single `git log + # --name-only` does this in one process invocation (rather than a `git show` per commit), so + # releases with large ranges stay fast. Each commit record is prefixed with a record separator + # (0x1e); within the header the hash and subject are split by a unit separator (0x1f); the + # file list then follows on subsequent lines (emitted by --name-only). $range = if ($latestTag) { "$latestTag..HEAD" } else { "HEAD" } - $rawCommits = Invoke-Git -Arguments @('log', $range, '--pretty=format:%s', '--', $packageFolder) - if ($null -eq $rawCommits -or $rawCommits.Count -eq 0) { - $rawCommits = @() - } else { - $rawCommits = @($rawCommits) + $recordSep = [char]0x1e + $unitSep = [char]0x1f + $logOutput = Invoke-Git -Arguments @('log', $range, '--name-only', "--pretty=format:${recordSep}%H${unitSep}%s", '--', $packageFolder) + $logText = @($logOutput) -join "`n" + + # Files in a package folder that are produced or maintained automatically rather than by a + # human authoring a change. README.md is regenerated by `just readme`, which rewrites every + # crate's README workspace-wide; an unrelated commit (e.g. one that introduced a *different* + # package) therefore "touches" this folder solely through that regeneration. CHANGELOG.md is + # release bookkeeping. A commit whose ONLY changes within this folder are such files did not + # actually change the package, so its subject must not leak into the changelog. + # + # Only the crate-ROOT README.md / CHANGELOG.md are auto-maintained. Nested files are + # hand-authored and must still count as meaningful changes — including ones that merely share + # the leaf name (crates//examples/README.md) AND ones that also share the parent leaf + # (crates///README.md). Matching on the parent leaf alone misclassifies the latter, + # so we build the exact repo-relative crate-root paths and require a full-path match. + # + # git emits repo-relative, forward-slash paths regardless of platform. The package's + # repo-relative directory is its last two path segments (crates/); deriving it this way + # avoids hard-coding the 'crates' prefix while still anchoring the match to the crate root. + $packageSegments = $packageFolder -split '[\\/]' | Where-Object { $_ } + $packageRelDir = ($packageSegments | Select-Object -Last 2) -join '/' + $autoMaintainedRootPaths = @('README.md', 'CHANGELOG.md') | ForEach-Object { "$packageRelDir/$_" } + + $rawCommits = @() + foreach ($record in ($logText -split $recordSep)) { + if ([string]::IsNullOrWhiteSpace($record)) { continue } + + $header, $fileBlock = $record -split "`n", 2 + $subject = ($header -split $unitSep, 2)[-1].Trim() + $changedFiles = @(($fileBlock -split "`n") | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + $hasMeaningfulChange = $false + foreach ($file in $changedFiles) { + if ($autoMaintainedRootPaths -notcontains $file) { + $hasMeaningfulChange = $true + break + } + } + if ($hasMeaningfulChange) { + $rawCommits += $subject + } } $formattedCommits = @() diff --git a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 index a19d79ef6..c7b3828c0 100644 --- a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 @@ -375,3 +375,103 @@ initial } } } + +Describe 'Write-Changelog commit filtering' { + # Regression: Write-Changelog must not attribute a commit to a package's + # changelog when the commit's ONLY change inside the package folder is an + # auto-maintained crate-root file (README.md / CHANGELOG.md). README.md is + # regenerated workspace-wide by `just readme`, so an unrelated commit can + # "touch" this package's folder solely through that regeneration. + # + # The single `git log --name-only` invocation is mocked. Its output encodes, + # per commit, a record separator (0x1e), the hash, a unit separator (0x1f), + # the subject, then the changed-file paths on following lines. + + BeforeEach { + Mock -CommandName Get-Date -MockWith { [datetime]'2026-06-15T00:00:00Z' } + + $script:ChangelogPath = Join-Path $TestDrive ("filter-" + [guid]::NewGuid().Guid.Substring(0, 8) + ".md") + Set-Content -LiteralPath $script:ChangelogPath -Value "# Changelog`n`n" -NoNewline -Encoding utf8 + } + + It 'excludes a commit whose only package-folder change is the crate-root README.md, but keeps a real source commit' { + $rs = [char]0x1e; $us = [char]0x1f + $script:LogText = "${rs}hReal${us}feat(pkg): genuine source change (#11)`n`ncrates/pkg/src/lib.rs`n${rs}hDoc${us}feat: introduce unrelated crate (#22)`n`ncrates/pkg/README.md" + + Mock -CommandName Invoke-Git -MockWith { + if ($Arguments -contains 'tag') { return @() } + if ($Arguments[0] -eq 'log') { return $script:LogText } + return @() + } + + Write-Changelog -packageName 'pkg' -newVersion '0.2.0' ` + -packageFolder (Join-Path $TestDrive 'crates\pkg') ` + -changelogFile $script:ChangelogPath -prBaseUrl 'http://x' ` + -WarningAction SilentlyContinue + + $content = Get-Content -LiteralPath $script:ChangelogPath -Raw + $content | Should -Match 'genuine source change' + $content | Should -Not -Match 'introduce unrelated crate' + } + + It 'keeps a commit that touches a NESTED README.md (e.g. examples/README.md), which is hand-authored' { + $rs = [char]0x1e; $us = [char]0x1f + $script:LogText = "${rs}hNested${us}docs(pkg): expand examples readme (#33)`n`ncrates/pkg/examples/README.md" + + Mock -CommandName Invoke-Git -MockWith { + if ($Arguments -contains 'tag') { return @() } + if ($Arguments[0] -eq 'log') { return $script:LogText } + return @() + } + + Write-Changelog -packageName 'pkg' -newVersion '0.2.0' ` + -packageFolder (Join-Path $TestDrive 'crates\pkg') ` + -changelogFile $script:ChangelogPath -prBaseUrl 'http://x' ` + -WarningAction SilentlyContinue + + $content = Get-Content -LiteralPath $script:ChangelogPath -Raw + $content | Should -Match 'expand examples readme' + } + + It 'keeps a commit touching a nested file that shares BOTH leaf and parent name (crates///README.md)' { + # Edge case: matching on the parent leaf alone would misclassify this as + # the crate root. The exact repo-relative path match keeps it. + $rs = [char]0x1e; $us = [char]0x1f + $script:LogText = "${rs}hNested${us}docs(pkg): nested same-named readme (#44)`n`ncrates/pkg/pkg/README.md" + + Mock -CommandName Invoke-Git -MockWith { + if ($Arguments -contains 'tag') { return @() } + if ($Arguments[0] -eq 'log') { return $script:LogText } + return @() + } + + Write-Changelog -packageName 'pkg' -newVersion '0.2.0' ` + -packageFolder (Join-Path $TestDrive 'crates\pkg') ` + -changelogFile $script:ChangelogPath -prBaseUrl 'http://x' ` + -WarningAction SilentlyContinue + + $content = Get-Content -LiteralPath $script:ChangelogPath -Raw + $content | Should -Match 'nested same-named readme' + } + + It 'warns and writes nothing when every in-range commit is crate-root README-only' { + $rs = [char]0x1e; $us = [char]0x1f + $script:LogText = "${rs}hDoc${us}feat: introduce unrelated crate (#22)`n`ncrates/pkg/README.md" + + Mock -CommandName Invoke-Git -MockWith { + if ($Arguments -contains 'tag') { return @() } + if ($Arguments[0] -eq 'log') { return $script:LogText } + return @() + } + + $before = Get-Content -LiteralPath $script:ChangelogPath -Raw + + Write-Changelog -packageName 'pkg' -newVersion '0.2.0' ` + -packageFolder (Join-Path $TestDrive 'crates\pkg') ` + -changelogFile $script:ChangelogPath -prBaseUrl 'http://x' ` + -WarningAction SilentlyContinue + + $after = Get-Content -LiteralPath $script:ChangelogPath -Raw + $after | Should -Be $before + } +}