Skip to content

Commit 532c3ba

Browse files
authored
Merge pull request #34 from dualentry/chore/release-changelog-notes
chore(release): promote unreleased changelog entries to new version
2 parents f6d8dfb + 94d1f4d commit 532c3ba

7 files changed

Lines changed: 177 additions & 30 deletions

File tree

‎.github/workflows/changelog.yml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
name: Changelog
2+
3+
on:
4+
pull_request:
5+
branches: [main]
6+
types: [opened, synchronize, reopened, labeled, unlabeled]
7+
8+
jobs:
9+
release-note:
10+
if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-changelog') }}
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
with:
15+
fetch-depth: 0
16+
- run: python3 scripts/check_changelog.py "origin/${{ github.base_ref }}"

‎CONTRIBUTING.md‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,20 +51,19 @@ uv run pytest --cov=dualentry_cli --cov-report=term-missing
5151
1. Create a branch from `main`
5252
2. Make your changes
5353
3. Ensure linting and tests pass
54-
4. If you touched dependencies, run `uv lock` and commit `uv.lock`
55-
5. Open a PR against `main`
54+
4. For user-facing changes, add a bullet under `## [Unreleased]` in `CHANGELOG.md`. CI fails PRs that touch `src/` without one; apply the `skip-changelog` label when the change isn't user-facing
55+
5. If you touched dependencies, run `uv lock` and commit `uv.lock`
56+
6. Open a PR against `main`
5657

5758
## Releasing
5859

5960
Releases are triggered by publishing a GitHub Release. CI builds binaries and updates the Homebrew tap automatically.
6061

61-
1. Update `CHANGELOG.md` with the new version and changes
62-
2. Commit and push to `main`
63-
3. Go to GitHub → Releases → **Draft a new release**
64-
4. Click **Choose a tag** → type the new version (e.g., `v0.2.0`) → **Create new tag**
65-
5. Set the title: `DualEntry CLI v0.2.0 — <summary>`
66-
6. Paste the changelog entry as the release body
67-
7. Click **Publish release**
62+
Run `python scripts/release.py patch` (or `minor`, `major`, or an explicit version) from `main`. It:
63+
64+
1. Moves the `## [Unreleased]` entries in `CHANGELOG.md` under the new version heading
65+
2. Bumps the version, commits, tags, and pushes
66+
3. Creates the GitHub Release with those entries above the auto-generated PR list
6867

6968
CI will:
7069
- Build binaries for macOS (arm64, x86_64) and Linux (x86_64)

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ ignore = [
131131
]
132132

133133
[tool.ruff.lint.per-file-ignores]
134-
"scripts/*" = ["INP001", "S603", "PLR0911"]
134+
"scripts/*" = ["INP001", "S603", "S607", "PLR0911"]
135135
"tests/*" = [
136136
"S101",
137137
"S105",

‎scripts/check_changelog.py‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Fail when a PR changes the CLI without adding a CHANGELOG.md Unreleased entry.
4+
5+
Usage:
6+
python scripts/check_changelog.py <base-ref> # e.g. origin/main
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import subprocess
12+
import sys
13+
from pathlib import Path
14+
15+
UNRELEASED = "## [Unreleased]"
16+
CHANGELOG = Path("CHANGELOG.md")
17+
# Changes under these paths ship to users and need a release note.
18+
USER_FACING = ("src/",)
19+
20+
21+
def unreleased_entries(changelog: str) -> set[str]:
22+
"""Non-empty lines of the Unreleased section."""
23+
if UNRELEASED not in changelog:
24+
return set()
25+
section = changelog.split(UNRELEASED, 1)[1].split("\n## [", 1)[0]
26+
return {line.strip() for line in section.splitlines() if line.strip()}
27+
28+
29+
def needs_note(changed_files: list[str]) -> bool:
30+
return any(path.startswith(USER_FACING) for path in changed_files)
31+
32+
33+
def git(*args: str) -> str:
34+
return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout
35+
36+
37+
def main() -> int:
38+
if len(sys.argv) != 2:
39+
print(__doc__)
40+
return 2
41+
base = git("merge-base", sys.argv[1], "HEAD").strip()
42+
43+
changed = git("diff", "--name-only", base, "HEAD").splitlines()
44+
if not needs_note(changed):
45+
print("No user-facing changes; release note not required.")
46+
return 0
47+
48+
head_entries = unreleased_entries(CHANGELOG.read_text())
49+
base_entries = unreleased_entries(git("show", f"{base}:{CHANGELOG}"))
50+
if head_entries - base_entries:
51+
print("Release note found.")
52+
return 0
53+
54+
print(
55+
f"This PR changes {', '.join(USER_FACING)} but adds no entry under `{UNRELEASED}` in {CHANGELOG}.\n"
56+
"Add a bullet describing the change, or apply the `skip-changelog` label if it isn't user-facing.",
57+
)
58+
return 1
59+
60+
61+
if __name__ == "__main__":
62+
sys.exit(main())

‎scripts/release.py‎

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
1111
This script:
1212
1. Bumps version in pyproject.toml and __init__.py
13-
2. Updates CHANGELOG.md with today's date
13+
2. Promotes the CHANGELOG.md Unreleased section to the new version
1414
3. Commits the changes
1515
4. Creates a git tag
1616
5. Pushes to GitHub
@@ -29,6 +29,7 @@
2929
PYPROJECT = ROOT / "pyproject.toml"
3030
INIT_FILE = ROOT / "src" / "dualentry_cli" / "__init__.py"
3131
CHANGELOG = ROOT / "CHANGELOG.md"
32+
UNRELEASED = "## [Unreleased]"
3233

3334

3435
def get_current_version() -> str:
@@ -78,25 +79,28 @@ def update_init(new_version: str) -> None:
7879
INIT_FILE.write_text(content)
7980

8081

81-
def update_changelog(new_version: str) -> None:
82-
"""Add new version entry to CHANGELOG.md if not already present."""
83-
content = CHANGELOG.read_text()
84-
today = datetime.now(UTC).strftime("%Y-%m-%d")
85-
new_entry = f"## [{new_version}] - {today}\n\n"
82+
def promote_unreleased(content: str, new_version: str, today: str) -> tuple[str, str]:
83+
"""Move the Unreleased section under a new version heading; return (content, notes)."""
84+
heading = f"## [{new_version}] - {today}"
8685

87-
# Check if this version is already in the changelog
8886
if f"## [{new_version}]" in content:
89-
# Just update the date
90-
content = re.sub(
91-
rf"## \[{re.escape(new_version)}\] - \d{{4}}-\d{{2}}-\d{{2}}",
92-
f"## [{new_version}] - {today}",
93-
content,
94-
)
87+
content = re.sub(rf"## \[{re.escape(new_version)}\] - \d{{4}}-\d{{2}}-\d{{2}}", heading, content)
88+
elif UNRELEASED in content:
89+
# Keep an empty Unreleased section on top for the next PR.
90+
content = content.replace(UNRELEASED, f"{UNRELEASED}\n\n{heading}", 1)
9591
else:
96-
# Add new entry after "# Changelog"
97-
content = content.replace("# Changelog\n", f"# Changelog\n\n{new_entry}")
92+
content = content.replace("# Changelog\n", f"# Changelog\n\n{UNRELEASED}\n\n{heading}\n", 1)
93+
94+
notes = content.split(heading, 1)[1].split("\n## [", 1)[0].strip()
95+
return content, notes
9896

97+
98+
def update_changelog(new_version: str) -> str:
99+
"""Promote Unreleased entries in CHANGELOG.md to new_version and return them."""
100+
today = datetime.now(UTC).strftime("%Y-%m-%d")
101+
content, notes = promote_unreleased(CHANGELOG.read_text(), new_version, today)
99102
CHANGELOG.write_text(content)
103+
return notes
100104

101105

102106
def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
@@ -160,7 +164,7 @@ def main() -> int:
160164
print("\n Updating version files...")
161165
update_pyproject(new_version)
162166
update_init(new_version)
163-
update_changelog(new_version)
167+
notes = update_changelog(new_version)
164168

165169
# Commit
166170
print("\n Committing changes...")
@@ -178,10 +182,11 @@ def main() -> int:
178182

179183
# Create GitHub release
180184
print("\n Creating GitHub release...")
181-
result = run(
182-
["gh", "release", "create", f"v{new_version}", "--title", f"v{new_version}", "--generate-notes"],
183-
check=False,
184-
)
185+
cmd = ["gh", "release", "create", f"v{new_version}", "--title", f"v{new_version}", "--generate-notes"]
186+
if notes:
187+
# gh places --notes above the generated PR list.
188+
cmd += ["--notes", notes]
189+
result = run(cmd, check=False)
185190
if result.returncode != 0:
186191
print(f" WARNING: Could not create GitHub release: {result.stderr}")
187192
print(" Create it manually at: https://github.com/dualentry/dualentry-cli/releases/new")

‎tests/test_check_changelog.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Tests for scripts/check_changelog.py."""
2+
3+
from __future__ import annotations
4+
5+
import importlib.util
6+
from pathlib import Path
7+
8+
_SPEC = importlib.util.spec_from_file_location("check_changelog", Path(__file__).resolve().parent.parent / "scripts" / "check_changelog.py")
9+
check = importlib.util.module_from_spec(_SPEC)
10+
_SPEC.loader.exec_module(check)
11+
12+
13+
def test_unreleased_entries():
14+
content = "# Changelog\n\n## [Unreleased]\n\n- New thing\n\n## [0.1.18] - 2026-09-01\n\n- Old thing\n"
15+
assert check.unreleased_entries(content) == {"- New thing"}
16+
17+
18+
def test_unreleased_entries_empty_or_missing():
19+
assert check.unreleased_entries("# Changelog\n\n## [Unreleased]\n\n## [0.1.18] - 2026-09-01\n\n- Old\n") == set()
20+
assert check.unreleased_entries("# Changelog\n\n## [0.1.18] - 2026-09-01\n\n- Old\n") == set()
21+
22+
23+
def test_needs_note():
24+
assert check.needs_note(["src/dualentry_cli/main.py", "README.md"])
25+
assert not check.needs_note(["README.md", "tests/test_x.py", ".github/workflows/ci.yml"])

‎tests/test_release.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Tests for scripts/release.py changelog handling."""
2+
3+
from __future__ import annotations
4+
5+
import importlib.util
6+
from pathlib import Path
7+
8+
_SPEC = importlib.util.spec_from_file_location("release", Path(__file__).resolve().parent.parent / "scripts" / "release.py")
9+
release = importlib.util.module_from_spec(_SPEC)
10+
_SPEC.loader.exec_module(release)
11+
12+
_TODAY = "2026-10-01"
13+
14+
15+
def test_promotes_unreleased_entries():
16+
content = "# Changelog\n\n## [Unreleased]\n\n- Add export-jobs commands\n\n## [0.1.18] - 2026-09-01\n\n- Old entry\n"
17+
new, notes = release.promote_unreleased(content, "0.1.19", _TODAY)
18+
assert new == ("# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-10-01\n\n- Add export-jobs commands\n\n## [0.1.18] - 2026-09-01\n\n- Old entry\n")
19+
assert notes == "- Add export-jobs commands"
20+
21+
22+
def test_empty_unreleased_gives_empty_notes():
23+
content = "# Changelog\n\n## [Unreleased]\n\n## [0.1.18] - 2026-09-01\n\n"
24+
new, notes = release.promote_unreleased(content, "0.1.19", _TODAY)
25+
assert new.startswith("# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-10-01\n\n## [0.1.18]")
26+
assert notes == ""
27+
28+
29+
def test_adds_unreleased_when_missing():
30+
content = "# Changelog\n\n## [0.1.18] - 2026-09-01\n\n"
31+
new, notes = release.promote_unreleased(content, "0.1.19", _TODAY)
32+
assert new.startswith("# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-10-01\n\n## [0.1.18]")
33+
assert notes == ""
34+
35+
36+
def test_existing_version_only_updates_date():
37+
content = "# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-09-30\n\n- Entry\n"
38+
new, notes = release.promote_unreleased(content, "0.1.19", _TODAY)
39+
assert new == "# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-10-01\n\n- Entry\n"
40+
assert notes == "- Entry"

0 commit comments

Comments
 (0)