Skip to content

Commit bcaf08a

Browse files
authored
chore(release): sync changelog and version markers to main during backports (#3903)
This PR implements the changes to sync both CHANGELOG.md and version next markers to the `main` branch when backports are processed. Key changes: - Adds git `diff`, `apply`, and `apply_check` helpers to the release tool. - Updates the GitHub helper to support custom PR base and auto-merge. - Updates `changelog_news.py` to support selective news merging and correct semver-sorted insertion, while preserving the static `Unreleased` section. - Updates `process_backports.py` to collect version marker diffs during cherry-pick, check if they apply cleanly to main, apply them, and report any failures in the PR body. These changes ensure that the `main` branch's changelog and version markers remain in sync with the release branch during the backport process.
1 parent 52c51be commit bcaf08a

18 files changed

Lines changed: 1442 additions & 107 deletions

.github/workflows/on_pr_closed.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,33 @@ jobs:
8181
"$PR_NUMBER" \
8282
--remote origin \
8383
--no-dry-run
84+
85+
complete_sync_changelog:
86+
if: |
87+
github.event.pull_request.merged == true &&
88+
contains(github.event.pull_request.labels.*.name, 'type: sync-changelog')
89+
runs-on: ubuntu-latest
90+
permissions:
91+
contents: write
92+
issues: write
93+
pull-requests: read
94+
steps:
95+
- name: Checkout repository
96+
uses: actions/checkout@v7
97+
with:
98+
fetch-depth: 0
99+
100+
- name: Setup Bazel
101+
uses: bazel-contrib/setup-bazel@0.19.0
102+
with:
103+
bazelisk-version: 1.20.0
104+
105+
- name: Complete Sync Changelog
106+
env:
107+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
108+
PR_NUMBER: ${{ github.event.pull_request.number }}
109+
run: |
110+
bazel run //tools/private/release -- complete-sync-changelog \
111+
--pr "$PR_NUMBER"
112+
113+

tests/tools/private/release/BUILD.bazel

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,15 @@ py_test(
2626
],
2727
)
2828

29+
py_test(
30+
name = "complete_sync_changelog_test",
31+
srcs = ["complete_sync_changelog_test.py"],
32+
deps = [
33+
":release_test_helper",
34+
"//tools/private/release:release_lib",
35+
],
36+
)
37+
2938
py_test(
3039
name = "create_rc_test",
3140
srcs = ["create_rc_test.py"],

tests/tools/private/release/add_backports_test.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ def test_add_backports_explicit_issue(self):
3535
self.assertIn("- [ ] #125", call_args[1])
3636
# Should also auto-add Tag RC0
3737
self.assertIn("- [ ] Tag RC0", call_args[1])
38+
self.assertIn("- [ ] Sync Changelog #124", call_args[1])
39+
self.assertIn("- [ ] Sync Changelog #125", call_args[1])
3840

3941
def test_add_backports_auto_discover_success(self):
4042
args = argparse.Namespace(issue=None, prs=["124"])
@@ -98,6 +100,7 @@ def test_add_backports_no_auto_add_rc_if_pending(self):
98100
self.assertNotIn("Tag RC1", call_args[1])
99101
# Tag RC0 should still be there
100102
self.assertIn("- [ ] Tag RC0", call_args[1])
103+
self.assertIn("- [ ] Sync Changelog #124", call_args[1])
101104

102105

103106
if __name__ == "__main__":

tests/tools/private/release/changelog_news_test.py

Lines changed: 126 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,9 @@ def test_update_changelog_with_news(self):
1616
1717
[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased
1818
19-
{#unreleased-removed}
20-
### Removed
21-
* Nothing removed.
22-
23-
{#unreleased-changed}
24-
### Changed
25-
* Nothing changed.
26-
27-
{#unreleased-fixed}
28-
### Fixed
29-
* Nothing fixed.
30-
31-
{#unreleased-added}
32-
### Added
33-
* Nothing added.
19+
Unreleased changes are tracked as individual files in the [news/](./news)
20+
directory, or view the [latest generated
21+
changelog](https://rules-python.readthedocs.io/en/latest/changelog.html).
3422
3523
{#v2-0-2}
3624
## [2.0.2] - 2026-05-14
@@ -408,6 +396,129 @@ def test_update_changelog_empty_news(self):
408396
self.assertNotIn("{#v3-0-0-fixed}", new_content)
409397
self.assertNotIn("{#v3-0-0-added}", new_content)
410398

399+
def test_update_changelog_selective_news_files(self):
400+
# Arrange
401+
changelog = """# Changelog
402+
403+
{#unreleased}
404+
## Unreleased
405+
406+
[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased
407+
408+
{#v2-0-2}
409+
## [2.0.2] - 2026-05-14
410+
411+
[2.0.2]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.2
412+
"""
413+
changelog_path = self.tmpdir / "CHANGELOG.md"
414+
changelog_path.write_text(changelog)
415+
416+
news_dir = self.tmpdir / "news"
417+
news_dir.mkdir()
418+
419+
# Create news files
420+
(news_dir / "123.fixed.md").write_text("Fix A")
421+
(news_dir / "456.fixed.md").write_text("Fix B")
422+
423+
# Act: Only process 123.fixed.md
424+
changelog_news.update_changelog(
425+
"2.0.3",
426+
"2026-06-16",
427+
changelog_path=changelog_path,
428+
news_dir=news_dir,
429+
news_files=[news_dir / "123.fixed.md"],
430+
)
431+
432+
# Assert
433+
# 1. Only 123.fixed.md should be deleted
434+
self.assertFalse((news_dir / "123.fixed.md").exists())
435+
self.assertTrue((news_dir / "456.fixed.md").exists())
436+
437+
new_content = changelog_path.read_text()
438+
439+
# 2. Only Fix A should be in the changelog
440+
self.assertIn("Fix A", new_content)
441+
self.assertNotIn("Fix B", new_content)
442+
443+
def test_update_changelog_insertion_point(self):
444+
# Arrange
445+
changelog = """# Changelog
446+
447+
{#unreleased}
448+
## Unreleased
449+
450+
[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased
451+
452+
{#v2-2-0}
453+
## [2.2.0] - 2026-06-30
454+
455+
[2.2.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.2.0
456+
457+
{#v2-0-0}
458+
## [2.0.0] - 2026-04-09
459+
460+
[2.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0
461+
"""
462+
changelog_path = self.tmpdir / "CHANGELOG.md"
463+
changelog_path.write_text(changelog)
464+
465+
news_dir = self.tmpdir / "news"
466+
news_dir.mkdir()
467+
(news_dir / "123.fixed.md").write_text("Fix in 2.1.0")
468+
469+
# Act: Insert 2.1.0
470+
changelog_news.update_changelog(
471+
"2.1.0",
472+
"2026-06-17",
473+
changelog_path=changelog_path,
474+
news_dir=news_dir,
475+
)
476+
477+
# Assert
478+
new_content = changelog_path.read_text()
479+
480+
# Verify 2.1.0 is inserted BEFORE 2.0.0 but AFTER 2.2.0
481+
idx_2_2_0 = new_content.index("{#v2-2-0}")
482+
idx_2_1_0 = new_content.index("{#v2-1-0}")
483+
idx_2_0_0 = new_content.index("{#v2-0-0}")
484+
485+
self.assertTrue(idx_2_2_0 < idx_2_1_0 < idx_2_0_0)
486+
self.assertIn("Fix in 2.1.0", new_content)
487+
488+
def test_update_changelog_insertion_point_too_small(self):
489+
# Arrange
490+
changelog = """# Changelog
491+
492+
{#unreleased}
493+
## Unreleased
494+
495+
[unreleased]: https://github.com/bazel-contrib/rules_python/releases/tag/unreleased
496+
497+
{#v2-0-0}
498+
## [2.0.0] - 2026-04-09
499+
500+
[2.0.0]: https://github.com/bazel-contrib/rules_python/releases/tag/2.0.0
501+
"""
502+
changelog_path = self.tmpdir / "CHANGELOG.md"
503+
changelog_path.write_text(changelog)
504+
505+
news_dir = self.tmpdir / "news"
506+
news_dir.mkdir()
507+
(news_dir / "123.fixed.md").write_text("Fix in 1.0.0")
508+
509+
# Act & Assert
510+
with self.assertRaises(ValueError) as ctx:
511+
changelog_news.update_changelog(
512+
"1.0.0",
513+
"2026-01-01",
514+
changelog_path=changelog_path,
515+
news_dir=news_dir,
516+
)
517+
self.assertIn(
518+
"Could not find a version in CHANGELOG.md smaller than 1.0.0",
519+
str(ctx.exception),
520+
)
521+
411522

412523
if __name__ == "__main__":
413524
unittest.main()
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import argparse
2+
import unittest
3+
from unittest.mock import patch
4+
5+
from tests.tools.private.release.release_test_helper import _mock_git_and_gh
6+
from tools.private.release.complete_sync_changelog import CompleteSyncChangelog
7+
8+
9+
class CompleteSyncChangelogTest(unittest.TestCase):
10+
def setUp(self):
11+
_mock_git_and_gh(self)
12+
self.addCleanup(patch.stopall)
13+
14+
# Dynamic mock for issue body
15+
self.issue_body = ""
16+
17+
def mock_get_body(issue_num):
18+
return self.issue_body
19+
20+
def mock_update_body(issue_num, body):
21+
self.issue_body = body
22+
23+
self.mock_gh.get_issue_body.side_effect = mock_get_body
24+
self.mock_gh.update_issue_body.side_effect = mock_update_body
25+
26+
def test_complete_sync_changelog_success(self):
27+
args = argparse.Namespace(pr=999)
28+
self.mock_gh.get_pr_info.return_value = {
29+
"state": "MERGED",
30+
"body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123",
31+
"mergeCommit": {"oid": "abcdef1234567890"},
32+
}
33+
self.issue_body = """
34+
## Checklist
35+
- [ ] Prepare Release
36+
- [ ] Create Release branch
37+
- [ ] Sync Changelog #124 | status=pending pr=#999
38+
- [ ] Sync Changelog #125 | status=pending pr=#999
39+
- [ ] Sync Changelog #126 | status=pending pr=#888
40+
- [ ] Tag Final
41+
42+
## Backports
43+
"""
44+
result = CompleteSyncChangelog(args, self.mock_gh).run()
45+
46+
self.assertEqual(result, 0)
47+
self.mock_gh.get_pr_info.assert_called_once_with(999)
48+
self.mock_gh.get_issue_body.assert_called_once_with(123)
49+
self.mock_gh.update_issue_body.assert_called_once()
50+
51+
# Check that only tasks pointing to #999 were marked checked=True and status=done
52+
self.assertIn(
53+
"- [x] Sync Changelog #124 | status=done pr=#999 commit= abcdef12",
54+
self.issue_body,
55+
)
56+
self.assertIn(
57+
"- [x] Sync Changelog #125 | status=done pr=#999 commit= abcdef12",
58+
self.issue_body,
59+
)
60+
# Task pointing to #888 should remain unchanged
61+
self.assertIn(
62+
"- [ ] Sync Changelog #126 | status=pending pr=#888",
63+
self.issue_body,
64+
)
65+
66+
def test_complete_sync_changelog_not_merged(self):
67+
args = argparse.Namespace(pr=999)
68+
self.mock_gh.get_pr_info.return_value = {
69+
"state": "OPEN",
70+
"body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123",
71+
}
72+
73+
result = CompleteSyncChangelog(args, self.mock_gh).run()
74+
75+
self.assertEqual(result, 1)
76+
self.mock_gh.get_pr_info.assert_called_once_with(999)
77+
self.mock_gh.update_issue_body.assert_not_called()
78+
79+
def test_complete_sync_changelog_missing_tracking_issue_link(self):
80+
args = argparse.Namespace(pr=999)
81+
self.mock_gh.get_pr_info.return_value = {
82+
"state": "MERGED",
83+
"body": "Updates CHANGELOG.md without tracking issue link",
84+
"mergeCommit": {"oid": "abcdef1234567890"},
85+
}
86+
87+
result = CompleteSyncChangelog(args, self.mock_gh).run()
88+
89+
self.assertEqual(result, 1)
90+
self.mock_gh.get_pr_info.assert_called_once_with(999)
91+
self.mock_gh.update_issue_body.assert_not_called()
92+
93+
def test_complete_sync_changelog_no_matching_tasks(self):
94+
args = argparse.Namespace(pr=999)
95+
self.mock_gh.get_pr_info.return_value = {
96+
"state": "MERGED",
97+
"body": "Updates CHANGELOG.md\n\nRelease-Tracking-Issue: #123",
98+
"mergeCommit": {"oid": "abcdef1234567890"},
99+
}
100+
# Checklist has no tasks pointing to #999
101+
self.issue_body = """
102+
## Checklist
103+
- [ ] Prepare Release
104+
- [ ] Create Release branch
105+
- [ ] Sync Changelog #124 | status=pending pr=#888
106+
- [ ] Tag Final
107+
108+
## Backports
109+
"""
110+
result = CompleteSyncChangelog(args, self.mock_gh).run()
111+
112+
# Should log warning but return 0 (success/noop)
113+
self.assertEqual(result, 0)
114+
self.mock_gh.get_pr_info.assert_called_once_with(999)
115+
self.mock_gh.get_issue_body.assert_called_once_with(123)
116+
self.mock_gh.update_issue_body.assert_not_called()
117+
118+
119+
if __name__ == "__main__":
120+
unittest.main()

0 commit comments

Comments
 (0)