Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/changelog.yml
Original file line number Diff line number Diff line change
@@ -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 }}"
17 changes: 8 additions & 9 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. 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`

## 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 — <summary>`
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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ ignore = [
]

[tool.ruff.lint.per-file-ignores]
"scripts/*" = ["INP001", "S603", "PLR0911"]
"scripts/*" = ["INP001", "S603", "S607", "PLR0911"]
"tests/*" = [
"S101",
"S105",
Expand Down
62 changes: 62 additions & 0 deletions scripts/check_changelog.py
Original file line number Diff line number Diff line change
@@ -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 <base-ref> # 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())
45 changes: 25 additions & 20 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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...")
Expand All @@ -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")
Expand Down
25 changes: 25 additions & 0 deletions tests/test_check_changelog.py
Original file line number Diff line number Diff line change
@@ -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"])
40 changes: 40 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
@@ -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"
Loading