From 34668f574e45e8d01a64adbd09270f9b3db26efc Mon Sep 17 00:00:00 2001 From: Taras Pashkevych Date: Tue, 29 Sep 2026 17:49:01 +0200 Subject: [PATCH 1/2] chore(release): promote unreleased changelog entries to new version --- CHANGELOG.md | 2 ++ CONTRIBUTING.md | 17 ++++++++-------- scripts/release.py | 45 ++++++++++++++++++++++++------------------- tests/test_release.py | 40 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 29 deletions(-) create mode 100644 tests/test_release.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e0634a3..16a55e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [Unreleased] + ## [0.1.18] - 2026-09-01 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f769776..579a948 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,20 +51,19 @@ uv run pytest --cov=dualentry_cli --cov-report=term-missing 1. Create a branch from `main` 2. Make your changes 3. Ensure linting and tests pass -4. If you touched dependencies, run `uv lock` and commit `uv.lock` -5. Open a PR against `main` +4. For user-facing changes, add a bullet under `## [Unreleased]` in `CHANGELOG.md` +5. If you touched dependencies, run `uv lock` and commit `uv.lock` +6. Open a PR against `main` ## Releasing Releases are triggered by publishing a GitHub Release. CI builds binaries and updates the Homebrew tap automatically. -1. Update `CHANGELOG.md` with the new version and changes -2. Commit and push to `main` -3. Go to GitHub → Releases → **Draft a new release** -4. Click **Choose a tag** → type the new version (e.g., `v0.2.0`) → **Create new tag** -5. Set the title: `DualEntry CLI v0.2.0 — ` -6. Paste the changelog entry as the release body -7. Click **Publish release** +Run `python scripts/release.py patch` (or `minor`, `major`, or an explicit version) from `main`. It: + +1. Moves the `## [Unreleased]` entries in `CHANGELOG.md` under the new version heading +2. Bumps the version, commits, tags, and pushes +3. Creates the GitHub Release with those entries above the auto-generated PR list CI will: - Build binaries for macOS (arm64, x86_64) and Linux (x86_64) diff --git a/scripts/release.py b/scripts/release.py index 4e9df48..9edaf79 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -10,7 +10,7 @@ This script: 1. Bumps version in pyproject.toml and __init__.py -2. Updates CHANGELOG.md with today's date +2. Promotes the CHANGELOG.md Unreleased section to the new version 3. Commits the changes 4. Creates a git tag 5. Pushes to GitHub @@ -29,6 +29,7 @@ PYPROJECT = ROOT / "pyproject.toml" INIT_FILE = ROOT / "src" / "dualentry_cli" / "__init__.py" CHANGELOG = ROOT / "CHANGELOG.md" +UNRELEASED = "## [Unreleased]" def get_current_version() -> str: @@ -78,25 +79,28 @@ def update_init(new_version: str) -> None: INIT_FILE.write_text(content) -def update_changelog(new_version: str) -> None: - """Add new version entry to CHANGELOG.md if not already present.""" - content = CHANGELOG.read_text() - today = datetime.now(UTC).strftime("%Y-%m-%d") - new_entry = f"## [{new_version}] - {today}\n\n" +def promote_unreleased(content: str, new_version: str, today: str) -> tuple[str, str]: + """Move the Unreleased section under a new version heading; return (content, notes).""" + heading = f"## [{new_version}] - {today}" - # Check if this version is already in the changelog if f"## [{new_version}]" in content: - # Just update the date - content = re.sub( - rf"## \[{re.escape(new_version)}\] - \d{{4}}-\d{{2}}-\d{{2}}", - f"## [{new_version}] - {today}", - content, - ) + content = re.sub(rf"## \[{re.escape(new_version)}\] - \d{{4}}-\d{{2}}-\d{{2}}", heading, content) + elif UNRELEASED in content: + # Keep an empty Unreleased section on top for the next PR. + content = content.replace(UNRELEASED, f"{UNRELEASED}\n\n{heading}", 1) else: - # Add new entry after "# Changelog" - content = content.replace("# Changelog\n", f"# Changelog\n\n{new_entry}") + content = content.replace("# Changelog\n", f"# Changelog\n\n{UNRELEASED}\n\n{heading}\n", 1) + + notes = content.split(heading, 1)[1].split("\n## [", 1)[0].strip() + return content, notes + +def update_changelog(new_version: str) -> str: + """Promote Unreleased entries in CHANGELOG.md to new_version and return them.""" + today = datetime.now(UTC).strftime("%Y-%m-%d") + content, notes = promote_unreleased(CHANGELOG.read_text(), new_version, today) CHANGELOG.write_text(content) + return notes def run(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess: @@ -160,7 +164,7 @@ def main() -> int: print("\n Updating version files...") update_pyproject(new_version) update_init(new_version) - update_changelog(new_version) + notes = update_changelog(new_version) # Commit print("\n Committing changes...") @@ -178,10 +182,11 @@ def main() -> int: # Create GitHub release print("\n Creating GitHub release...") - result = run( - ["gh", "release", "create", f"v{new_version}", "--title", f"v{new_version}", "--generate-notes"], - check=False, - ) + cmd = ["gh", "release", "create", f"v{new_version}", "--title", f"v{new_version}", "--generate-notes"] + if notes: + # gh places --notes above the generated PR list. + cmd += ["--notes", notes] + result = run(cmd, check=False) if result.returncode != 0: print(f" WARNING: Could not create GitHub release: {result.stderr}") print(" Create it manually at: https://github.com/dualentry/dualentry-cli/releases/new") diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..6924eea --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,40 @@ +"""Tests for scripts/release.py changelog handling.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location("release", Path(__file__).resolve().parent.parent / "scripts" / "release.py") +release = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(release) + +_TODAY = "2026-10-01" + + +def test_promotes_unreleased_entries(): + content = "# Changelog\n\n## [Unreleased]\n\n- Add export-jobs commands\n\n## [0.1.18] - 2026-09-01\n\n- Old entry\n" + new, notes = release.promote_unreleased(content, "0.1.19", _TODAY) + 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") + assert notes == "- Add export-jobs commands" + + +def test_empty_unreleased_gives_empty_notes(): + content = "# Changelog\n\n## [Unreleased]\n\n## [0.1.18] - 2026-09-01\n\n" + new, notes = release.promote_unreleased(content, "0.1.19", _TODAY) + assert new.startswith("# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-10-01\n\n## [0.1.18]") + assert notes == "" + + +def test_adds_unreleased_when_missing(): + content = "# Changelog\n\n## [0.1.18] - 2026-09-01\n\n" + new, notes = release.promote_unreleased(content, "0.1.19", _TODAY) + assert new.startswith("# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-10-01\n\n## [0.1.18]") + assert notes == "" + + +def test_existing_version_only_updates_date(): + content = "# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-09-30\n\n- Entry\n" + new, notes = release.promote_unreleased(content, "0.1.19", _TODAY) + assert new == "# Changelog\n\n## [Unreleased]\n\n## [0.1.19] - 2026-10-01\n\n- Entry\n" + assert notes == "- Entry" From cba3748e12de7d2acb624ffe29248d43ded840cb Mon Sep 17 00:00:00 2001 From: Taras Pashkevych Date: Wed, 30 Sep 2026 14:34:09 +0200 Subject: [PATCH 2/2] ci(changelog): require an unreleased entry for prs that change src --- .github/workflows/changelog.yml | 16 +++++++++ CONTRIBUTING.md | 2 +- pyproject.toml | 2 +- scripts/check_changelog.py | 62 +++++++++++++++++++++++++++++++++ tests/test_check_changelog.py | 25 +++++++++++++ 5 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/changelog.yml create mode 100755 scripts/check_changelog.py create mode 100644 tests/test_check_changelog.py diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..119bf73 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,16 @@ +name: Changelog + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + release-note: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - run: python3 scripts/check_changelog.py "origin/${{ github.base_ref }}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 579a948..3b2aa08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,7 +51,7 @@ uv run pytest --cov=dualentry_cli --cov-report=term-missing 1. Create a branch from `main` 2. Make your changes 3. Ensure linting and tests pass -4. For user-facing changes, add a bullet under `## [Unreleased]` in `CHANGELOG.md` +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 5. If you touched dependencies, run `uv lock` and commit `uv.lock` 6. Open a PR against `main` diff --git a/pyproject.toml b/pyproject.toml index d961871..db19fb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,7 +131,7 @@ ignore = [ ] [tool.ruff.lint.per-file-ignores] -"scripts/*" = ["INP001", "S603", "PLR0911"] +"scripts/*" = ["INP001", "S603", "S607", "PLR0911"] "tests/*" = [ "S101", "S105", diff --git a/scripts/check_changelog.py b/scripts/check_changelog.py new file mode 100755 index 0000000..0426367 --- /dev/null +++ b/scripts/check_changelog.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Fail when a PR changes the CLI without adding a CHANGELOG.md Unreleased entry. + +Usage: + python scripts/check_changelog.py # e.g. origin/main +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +UNRELEASED = "## [Unreleased]" +CHANGELOG = Path("CHANGELOG.md") +# Changes under these paths ship to users and need a release note. +USER_FACING = ("src/",) + + +def unreleased_entries(changelog: str) -> set[str]: + """Non-empty lines of the Unreleased section.""" + if UNRELEASED not in changelog: + return set() + section = changelog.split(UNRELEASED, 1)[1].split("\n## [", 1)[0] + return {line.strip() for line in section.splitlines() if line.strip()} + + +def needs_note(changed_files: list[str]) -> bool: + return any(path.startswith(USER_FACING) for path in changed_files) + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__) + return 2 + base = git("merge-base", sys.argv[1], "HEAD").strip() + + changed = git("diff", "--name-only", base, "HEAD").splitlines() + if not needs_note(changed): + print("No user-facing changes; release note not required.") + return 0 + + head_entries = unreleased_entries(CHANGELOG.read_text()) + base_entries = unreleased_entries(git("show", f"{base}:{CHANGELOG}")) + if head_entries - base_entries: + print("Release note found.") + return 0 + + print( + f"This PR changes {', '.join(USER_FACING)} but adds no entry under `{UNRELEASED}` in {CHANGELOG}.\n" + "Add a bullet describing the change, or apply the `skip-changelog` label if it isn't user-facing.", + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_changelog.py b/tests/test_check_changelog.py new file mode 100644 index 0000000..2c1a297 --- /dev/null +++ b/tests/test_check_changelog.py @@ -0,0 +1,25 @@ +"""Tests for scripts/check_changelog.py.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +_SPEC = importlib.util.spec_from_file_location("check_changelog", Path(__file__).resolve().parent.parent / "scripts" / "check_changelog.py") +check = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(check) + + +def test_unreleased_entries(): + content = "# Changelog\n\n## [Unreleased]\n\n- New thing\n\n## [0.1.18] - 2026-09-01\n\n- Old thing\n" + assert check.unreleased_entries(content) == {"- New thing"} + + +def test_unreleased_entries_empty_or_missing(): + assert check.unreleased_entries("# Changelog\n\n## [Unreleased]\n\n## [0.1.18] - 2026-09-01\n\n- Old\n") == set() + assert check.unreleased_entries("# Changelog\n\n## [0.1.18] - 2026-09-01\n\n- Old\n") == set() + + +def test_needs_note(): + assert check.needs_note(["src/dualentry_cli/main.py", "README.md"]) + assert not check.needs_note(["README.md", "tests/test_x.py", ".github/workflows/ci.yml"])