diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aeacabab..67568a797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A rebase across a release can no longer move changelog entries into a + published version.** When a release is cut while a branch is open, rebasing + that branch drops its entries into the version section that was just + materialized — and because the `### Fixed` / `### Added` headings match on + both sides, git does it without a conflict, so nothing warns and the entries + are silently attributed to a release that never contained them. This happened + on four branches in a row. The changelog gate now compares everything from + the first released version heading down against the base branch and fails if + it differs, naming the fix; a deliberate edit of released history uses a + `docs(changelog)` commit. - **The e2e VictoriaLogs restarts were OOM kills, and it now has the memory it needs.** The earlier diagnosis was wrong: `docker inspect` reports the state of the container that is already running again, which showed `exit 0` and diff --git a/scripts/ci/check_changelog_pr.py b/scripts/ci/check_changelog_pr.py index 9de12cd55..9b8979ec3 100644 --- a/scripts/ci/check_changelog_pr.py +++ b/scripts/ci/check_changelog_pr.py @@ -221,6 +221,29 @@ def is_dependency_only_pr(commits: Iterable[str], files: Iterable[str]) -> bool: ) +def extract_released_sections(text: str) -> str: + """Everything from the first released version heading down. + + A rebase across a release routinely drops a feature branch's entries into + the version section that was cut while the branch was open, and git does it + without a conflict when the section headings match, so nothing warns. The + released history is immutable once published: comparing this slice against + the base catches that silently. + """ + match = re.search(r"^## \[\d[^\]]*\]", text, re.MULTILINE) + if not match: + return "" + return text[match.start():] + + +def is_changelog_history_fix(commits: Iterable[str]) -> bool: + """True when the pull request deliberately edits released history.""" + subjects = [c for c in commits if c.strip() and not is_merge_commit(c)] + if not subjects: + return False + return all(c.strip().lower().startswith("docs(changelog)") for c in subjects) + + def is_release_metadata_sync(files: Iterable[str]) -> bool: file_list = [f for f in files if f.strip()] if not file_list or "CHANGELOG.md" not in file_list: @@ -264,6 +287,21 @@ def main() -> int: print("changelog gate: ok (release metadata sync)") return 0 + if ( + "CHANGELOG.md" in files + and not is_changelog_history_fix(commits) + and extract_released_sections(head_text) != extract_released_sections(base_text) + ): + print( + "changelog gate: released version sections must match the base branch — " + "entries belong under [Unreleased]. A rebase across a release moves them " + "into the version that was cut while the branch was open, without a " + "conflict. Rebuild CHANGELOG.md from the base and re-insert your entries " + "under [Unreleased] (a deliberate history fix uses a docs(changelog) commit).", + file=sys.stderr, + ) + return 1 + if not should_require_changelog(commits, files): print("changelog gate: skipped (no releasable changes detected)") return 0 diff --git a/scripts/ci/tests/test_check_changelog_pr.py b/scripts/ci/tests/test_check_changelog_pr.py index f00f3398d..f4b4555de 100644 --- a/scripts/ci/tests/test_check_changelog_pr.py +++ b/scripts/ci/tests/test_check_changelog_pr.py @@ -5,6 +5,8 @@ has_genuinely_new_unreleased_entries, has_meaningful_changelog_content, has_new_version_sections, + extract_released_sections, + is_changelog_history_fix, is_dependency_only_pr, is_release_metadata_sync, should_require_changelog, @@ -108,6 +110,31 @@ def test_dependency_only_pr_go_modules(self): ) ) + def test_extract_released_sections_starts_at_first_version(self): + text = """# Changelog + +## [Unreleased] + +### Fixed + +- something new + +## [1.2.0] - 2026-01-01 + +- released entry +""" + released = extract_released_sections(text) + self.assertTrue(released.startswith("## [1.2.0]")) + self.assertNotIn("something new", released) + + def test_extract_released_sections_empty_when_nothing_released(self): + self.assertEqual(extract_released_sections("# Changelog\n\n## [Unreleased]\n"), "") + + def test_changelog_history_fix_detects_docs_changelog_commits(self): + self.assertTrue(is_changelog_history_fix(["docs(changelog): move entry back to Unreleased"])) + self.assertFalse(is_changelog_history_fix(["fix: something"])) + self.assertFalse(is_changelog_history_fix([])) + def test_dependency_only_pr_survives_a_branch_update(self): """Bringing a dependency branch up to date must not make it releasable.""" self.assertTrue(