From c923f239b243a900fc52ab2917fdf72604ff2800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kate=C5=99ina=20Churanov=C3=A1?= Date: Thu, 11 Jun 2026 10:34:39 +0200 Subject: [PATCH 1/3] fix(scripts): exclude auto-maintained-file-only commits from generated changelogs just readme regenerates every crate's README.md workspace-wide, so a commit that only introduced an unrelated package still touches this package's folder via that regeneration. Path-scoped git log then leaked its subject into the wrong changelog. Skip commits whose only in-folder changes are README.md/CHANGELOG.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/release-flow.ps1 | 30 +++++++-- .../unit/releasing/WriteChangelog.Tests.ps1 | 63 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index bafdb4fa6..344c920f0 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -776,13 +776,31 @@ 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. $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) + $commitHashes = @(Invoke-Git -Arguments @('log', $range, '--pretty=format:%H', '--', $packageFolder) | Where-Object { $_ }) + + # 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. + $autoMaintainedFiles = @('README.md', 'CHANGELOG.md') + + $rawCommits = @() + foreach ($hash in $commitHashes) { + $changedFiles = @(Invoke-Git -Arguments @('show', $hash, '--name-only', '--pretty=format:', '--', $packageFolder) | Where-Object { $_ }) + $hasMeaningfulChange = $false + foreach ($file in $changedFiles) { + if ($autoMaintainedFiles -notcontains (Split-Path $file -Leaf)) { + $hasMeaningfulChange = $true + break + } + } + if ($hasMeaningfulChange) { + $rawCommits += Invoke-Git -Arguments @('show', '-s', '--pretty=format:%s', $hash) + } } $formattedCommits = @() diff --git a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 index a19d79ef6..7424f34e3 100644 --- a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 @@ -375,3 +375,66 @@ 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 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. + + 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 README.md, but keeps a real source commit' { + # Two commits in range: one genuinely changed src/, one only rewrote README.md. + Mock -CommandName Invoke-Git -MockWith { + if ($Arguments -contains 'tag') { return @() } + if ($Arguments[0] -eq 'log') { return @('hReal', 'hDoc') } + if ($Arguments[0] -eq 'show' -and ($Arguments -contains '--name-only')) { + if ($Arguments -contains 'hReal') { return @('crates/pkg/src/lib.rs') } + if ($Arguments -contains 'hDoc') { return @('crates/pkg/README.md') } + return @() + } + if ($Arguments[0] -eq 'show' -and ($Arguments -contains '-s')) { + if ($Arguments -contains 'hReal') { return @('feat(pkg): genuine source change (#11)') } + if ($Arguments -contains 'hDoc') { return @('feat: introduce unrelated crate (#22)') } + return @() + } + 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 'warns and writes nothing when every in-range commit is README-only' { + Mock -CommandName Invoke-Git -MockWith { + if ($Arguments -contains 'tag') { return @() } + if ($Arguments[0] -eq 'log') { return @('hDoc') } + if ($Arguments[0] -eq 'show' -and ($Arguments -contains '--name-only')) { return @('crates/pkg/README.md') } + if ($Arguments[0] -eq 'show' -and ($Arguments -contains '-s')) { return @('feat: introduce unrelated crate (#22)') } + 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 + } +} From 4a308578f145e2ea86ebfc7d9440585b89f8ff17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kate=C5=99ina=20Churanov=C3=A1?= Date: Thu, 11 Jun 2026 10:39:22 +0200 Subject: [PATCH 2/3] refactor(scripts): single git log for changelog filtering, match crate-root docs only Address review on PR #490: - Only crate-root README.md/CHANGELOG.md are auto-maintained; nested files like crates//examples/README.md (matched solely by leaf name before) are hand-authored and must count as meaningful changes. Now match on the parent directory being the package folder. - Replace the per-commit 'git show' calls (1 log + up to 2 show per commit) with a single 'git log --name-only', parsing record/unit separators in PowerShell, so large ranges no longer fan out into many git processes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/release-flow.ps1 | 36 ++++++++++-- .../unit/releasing/WriteChangelog.Tests.ps1 | 56 ++++++++++++------- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index 344c920f0..01b26fd72 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -776,9 +776,17 @@ function Write-Changelog { $currentDate = (Get-Date).ToString('yyyy-MM-dd') - # Get commits since the latest tag (unreleased commits) that touched this package. + # 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" } - $commitHashes = @(Invoke-Git -Arguments @('log', $range, '--pretty=format:%H', '--', $packageFolder) | Where-Object { $_ }) + $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 @@ -786,20 +794,36 @@ function Write-Changelog { # 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 that happen to + # share the same leaf name (e.g. crates//examples/README.md) are hand-authored docs and + # must still count as meaningful changes, so we match on the parent directory being the package + # folder itself, not merely on the leaf name. $autoMaintainedFiles = @('README.md', 'CHANGELOG.md') + $folderLeaf = Split-Path $packageFolder -Leaf $rawCommits = @() - foreach ($hash in $commitHashes) { - $changedFiles = @(Invoke-Git -Arguments @('show', $hash, '--name-only', '--pretty=format:', '--', $packageFolder) | Where-Object { $_ }) + 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 ($autoMaintainedFiles -notcontains (Split-Path $file -Leaf)) { + # git emits repo-relative, forward-slash paths regardless of platform. + $segments = $file -split '/' + $leaf = $segments[-1] + $parentLeaf = if ($segments.Count -ge 2) { $segments[-2] } else { '' } + $isAutoMaintainedRoot = ($autoMaintainedFiles -contains $leaf) -and ($parentLeaf -eq $folderLeaf) + if (-not $isAutoMaintainedRoot) { $hasMeaningfulChange = $true break } } if ($hasMeaningfulChange) { - $rawCommits += Invoke-Git -Arguments @('show', '-s', '--pretty=format:%s', $hash) + $rawCommits += $subject } } diff --git a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 index 7424f34e3..db7c326f1 100644 --- a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 @@ -379,9 +379,13 @@ 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 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. + # 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' } @@ -390,21 +394,13 @@ Describe 'Write-Changelog commit filtering' { Set-Content -LiteralPath $script:ChangelogPath -Value "# Changelog`n`n" -NoNewline -Encoding utf8 } - It 'excludes a commit whose only package-folder change is README.md, but keeps a real source commit' { - # Two commits in range: one genuinely changed src/, one only rewrote README.md. + 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 @('hReal', 'hDoc') } - if ($Arguments[0] -eq 'show' -and ($Arguments -contains '--name-only')) { - if ($Arguments -contains 'hReal') { return @('crates/pkg/src/lib.rs') } - if ($Arguments -contains 'hDoc') { return @('crates/pkg/README.md') } - return @() - } - if ($Arguments[0] -eq 'show' -and ($Arguments -contains '-s')) { - if ($Arguments -contains 'hReal') { return @('feat(pkg): genuine source change (#11)') } - if ($Arguments -contains 'hDoc') { return @('feat: introduce unrelated crate (#22)') } - return @() - } + if ($Arguments[0] -eq 'log') { return $script:LogText } return @() } @@ -418,12 +414,32 @@ Describe 'Write-Changelog commit filtering' { $content | Should -Not -Match 'introduce unrelated crate' } - It 'warns and writes nothing when every in-range commit is README-only' { + 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 '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 @('hDoc') } - if ($Arguments[0] -eq 'show' -and ($Arguments -contains '--name-only')) { return @('crates/pkg/README.md') } - if ($Arguments[0] -eq 'show' -and ($Arguments -contains '-s')) { return @('feat: introduce unrelated crate (#22)') } + if ($Arguments[0] -eq 'log') { return $script:LogText } return @() } From 356a5d1cae5f0c80ff0c6d421368109e2ee9d4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kate=C5=99ina=20Churanov=C3=A1?= Date: Thu, 11 Jun 2026 12:48:18 +0200 Subject: [PATCH 3/3] fix(scripts): match crate-root docs by exact relative path, not parent leaf Address follow-up review on PR #490: comparing only the parent directory's leaf name to the package folder name misclassifies crates///README.md as the crate root. Build the exact repo-relative crate-root paths (crates//README.md, crates//CHANGELOG.md) from the package folder's last two segments and require a full-path match. Added a regression test for the same-leaf-and-parent nested case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/release-flow.ps1 | 25 ++++++++++--------- .../unit/releasing/WriteChangelog.Tests.ps1 | 21 ++++++++++++++++ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index 01b26fd72..db7cdf97b 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -795,12 +795,18 @@ function Write-Changelog { # 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 that happen to - # share the same leaf name (e.g. crates//examples/README.md) are hand-authored docs and - # must still count as meaningful changes, so we match on the parent directory being the package - # folder itself, not merely on the leaf name. - $autoMaintainedFiles = @('README.md', 'CHANGELOG.md') - $folderLeaf = Split-Path $packageFolder -Leaf + # 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)) { @@ -812,12 +818,7 @@ function Write-Changelog { $hasMeaningfulChange = $false foreach ($file in $changedFiles) { - # git emits repo-relative, forward-slash paths regardless of platform. - $segments = $file -split '/' - $leaf = $segments[-1] - $parentLeaf = if ($segments.Count -ge 2) { $segments[-2] } else { '' } - $isAutoMaintainedRoot = ($autoMaintainedFiles -contains $leaf) -and ($parentLeaf -eq $folderLeaf) - if (-not $isAutoMaintainedRoot) { + if ($autoMaintainedRootPaths -notcontains $file) { $hasMeaningfulChange = $true break } diff --git a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 index db7c326f1..c7b3828c0 100644 --- a/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/WriteChangelog.Tests.ps1 @@ -433,6 +433,27 @@ Describe 'Write-Changelog commit filtering' { $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"