feat: automate changelog-backed releases
This commit is contained in:
parent
c0fe95235f
commit
17e5dc6ee9
|
|
@ -78,9 +78,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Extract release notes from changelog
|
||||
run: python3 scripts/changelog.py extract --version "${GITHUB_REF_NAME#v}" --output RELEASE_NOTES.md
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
|
|
@ -89,4 +94,4 @@ jobs:
|
|||
herdr-linux-aarch64/herdr-linux-aarch64
|
||||
herdr-macos-x86_64/herdr-macos-x86_64
|
||||
herdr-macos-aarch64/herdr-macos-aarch64
|
||||
generate_release_notes: true
|
||||
body_path: RELEASE_NOTES.md
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
/target
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
description: Draft changelog entry from commits and PRs since last tag
|
||||
---
|
||||
Draft a changelog entry for this repo.
|
||||
|
||||
Optional starting ref override: `$1`
|
||||
Extra user intent/context: `${@:2}`
|
||||
|
||||
Process:
|
||||
|
||||
1. Determine the base ref.
|
||||
- If `$1` is non-empty and looks like a ref/tag, use it.
|
||||
- Otherwise use the latest release tag, preferring the repo's semver tag style (for example `v0.1.2`):
|
||||
```bash
|
||||
git describe --tags --abbrev=0
|
||||
```
|
||||
|
||||
2. Inspect the range from base ref to `HEAD`.
|
||||
- Use first-parent history for release context:
|
||||
```bash
|
||||
git log --first-parent --reverse --format='%H%x09%s' <base>..HEAD
|
||||
```
|
||||
- Also inspect full commits when needed:
|
||||
```bash
|
||||
git log --reverse --format='%H%x09%s' <base>..HEAD
|
||||
```
|
||||
|
||||
3. Detect merged PRs if any.
|
||||
- Look for first-parent subjects that indicate PR merges, including squash merges like `title (#123)`.
|
||||
- If GitHub CLI is available and the PR number is known, use it to fetch PR title/body for context.
|
||||
- Treat a merged PR as the primary release unit.
|
||||
- Do **not** also list the individual commits that belong to that PR.
|
||||
|
||||
4. Handle direct commits separately.
|
||||
- Any commit in the range not represented by a merged PR should be considered on its own.
|
||||
|
||||
5. Infer what matters.
|
||||
- For each PR or direct commit, inspect changed files and diff stats.
|
||||
- Read the most relevant files in full when needed to understand user-facing impact.
|
||||
- Ignore pure housekeeping unless it has release value:
|
||||
- version bumps
|
||||
- release/tag commits
|
||||
- changelog-only commits
|
||||
- formatting-only changes
|
||||
- comment-only/doc-only changes unless they materially affect users
|
||||
|
||||
6. Draft the changelog entry.
|
||||
- Group items under these sections when applicable:
|
||||
- `### Added`
|
||||
- `### Changed`
|
||||
- `### Fixed`
|
||||
- `### Removed`
|
||||
- `### Breaking Changes`
|
||||
- Write for end users, not for commit archaeology.
|
||||
- Merge related low-level commits into one human-readable bullet when appropriate.
|
||||
- Keep bullets concrete and outcome-focused.
|
||||
- Prefer one bullet per meaningful shipped change.
|
||||
- If there are both PRs and direct commits, include both, but exclude direct commits already covered by PRs.
|
||||
|
||||
7. Respect repo reality.
|
||||
- If `CHANGELOG.md` exists, read it before proposing edits and follow its existing style.
|
||||
- If no changelog file exists, say so explicitly and produce a draft entry only.
|
||||
- Do not edit files yet unless the user explicitly asks you to apply the draft.
|
||||
|
||||
Output format:
|
||||
|
||||
- `Base ref:`
|
||||
- `PRs included:`
|
||||
- `Direct commits included:`
|
||||
- `Excluded as housekeeping:`
|
||||
- `Proposed changelog entry:`
|
||||
|
||||
If the range has no meaningful user-facing changes, say that plainly instead of forcing entries.
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Added optional sound notifications for agent state changes, including a completion chime when background work finishes and an alert when an agent needs input.
|
||||
- Added per-agent sound overrides under `[ui.sound.agents]`, so you can mute or enable notifications by agent instead of using one global setting. Droid notifications are muted by default.
|
||||
|
||||
### Changed
|
||||
- Request alerts now play even when the agent is in the active workspace, while completion sounds remain limited to background workspaces.
|
||||
|
||||
### Fixed
|
||||
- Improved foreground job detection on Linux and macOS so herdr can recognize agents that run through wrapper processes or generic runtimes, including cases like Codex running under `node`.
|
||||
- Made Claude Code state detection more stable by handling more spinner variants and smoothing short busy/idle flicker during screen updates.
|
||||
|
||||
## [0.1.0] - 2026-03-27
|
||||
|
||||
### Added
|
||||
- Initial release.
|
||||
8
justfile
8
justfile
|
|
@ -3,11 +3,13 @@
|
|||
# Run unit tests
|
||||
test:
|
||||
cargo test
|
||||
python3 -m unittest scripts.test_changelog
|
||||
|
||||
# Check formatting + run unit tests
|
||||
check:
|
||||
cargo fmt --check
|
||||
cargo test
|
||||
python3 -m unittest scripts.test_changelog
|
||||
|
||||
# Run integration tests (LLM-based, requires pi + tmux)
|
||||
test-integration:
|
||||
|
|
@ -29,7 +31,7 @@ clean-tests:
|
|||
@rm -f tests/integration/results/*.json tests/integration/results/*.txt 2>/dev/null || true
|
||||
@echo "cleaned"
|
||||
|
||||
# Bump version, commit, tag, push, trigger release build (usage: just release 0.1.1)
|
||||
# Finalize changelog, bump version, commit, tag, push, trigger release build (usage: just release 0.1.1)
|
||||
release version:
|
||||
@if [ -n "$(git status --porcelain)" ]; then \
|
||||
echo "error: commit your changes first"; \
|
||||
|
|
@ -39,9 +41,11 @@ release version:
|
|||
echo "error: tag v{{version}} already exists"; \
|
||||
exit 1; \
|
||||
fi
|
||||
python3 scripts/changelog.py prepare --version {{version}}
|
||||
sed -i.bak 's/^version = ".*"/version = "{{version}}"/' Cargo.toml && rm -f Cargo.toml.bak
|
||||
cargo test --quiet
|
||||
git add Cargo.toml Cargo.lock
|
||||
python3 -m unittest scripts.test_changelog
|
||||
git add CHANGELOG.md Cargo.toml Cargo.lock
|
||||
git diff --cached --quiet || git commit -m "release: v{{version}}"
|
||||
git tag v{{version}}
|
||||
git push --follow-tags
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
SECTION_RE = re.compile(r"^##\s+(?:\[(?P<bracketed>[^\]]+)\]|(?P<plain>.+?))\s*$", re.MULTILINE)
|
||||
VERSION_WITH_DATE_RE = re.compile(r"^(?P<version>.+?)\s+-\s+\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Section:
|
||||
title: str
|
||||
start: int
|
||||
end: int
|
||||
body_start: int
|
||||
|
||||
|
||||
class ChangelogError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def normalize_title(raw_title: str) -> str:
|
||||
title = raw_title.strip()
|
||||
match = VERSION_WITH_DATE_RE.match(title)
|
||||
if match:
|
||||
title = match.group("version").strip()
|
||||
if title.startswith("[") and title.endswith("]"):
|
||||
title = title[1:-1].strip()
|
||||
return title
|
||||
|
||||
|
||||
def parse_sections(text: str) -> list[Section]:
|
||||
matches = list(SECTION_RE.finditer(text))
|
||||
sections: list[Section] = []
|
||||
|
||||
for index, match in enumerate(matches):
|
||||
title = normalize_title(match.group("bracketed") or match.group("plain") or "")
|
||||
end = matches[index + 1].start() if index + 1 < len(matches) else len(text)
|
||||
body_start = match.end()
|
||||
if body_start < len(text) and text[body_start : body_start + 1] == "\n":
|
||||
body_start += 1
|
||||
sections.append(Section(title=title, start=match.start(), end=end, body_start=body_start))
|
||||
|
||||
return sections
|
||||
|
||||
|
||||
def find_section(text: str, wanted_title: str) -> Section:
|
||||
for section in parse_sections(text):
|
||||
if section.title == wanted_title:
|
||||
return section
|
||||
raise ChangelogError(f"section not found: {wanted_title}")
|
||||
|
||||
|
||||
def extract_section_body(text: str, wanted_title: str) -> str:
|
||||
section = find_section(text, wanted_title)
|
||||
body = text[section.body_start : section.end].strip("\n")
|
||||
if not body.strip():
|
||||
raise ChangelogError(f"section is empty: {wanted_title}")
|
||||
return body + "\n"
|
||||
|
||||
|
||||
def prepare_release(text: str, version: str, release_date: str) -> str:
|
||||
unreleased = None
|
||||
existing_version = False
|
||||
|
||||
for section in parse_sections(text):
|
||||
if section.title == "Unreleased":
|
||||
unreleased = section
|
||||
if section.title == version:
|
||||
existing_version = True
|
||||
|
||||
if existing_version:
|
||||
raise ChangelogError(f"version already exists in changelog: {version}")
|
||||
if unreleased is None:
|
||||
raise ChangelogError("missing Unreleased section")
|
||||
|
||||
unreleased_body = text[unreleased.body_start : unreleased.end].strip("\n")
|
||||
if not unreleased_body.strip():
|
||||
raise ChangelogError("Unreleased section is empty")
|
||||
|
||||
prefix = text[: unreleased.start].rstrip("\n")
|
||||
suffix = text[unreleased.end :].strip("\n")
|
||||
|
||||
rebuilt = f"## Unreleased\n\n## [{version}] - {release_date}\n\n{unreleased_body}"
|
||||
if suffix:
|
||||
rebuilt += f"\n\n{suffix}"
|
||||
|
||||
if prefix:
|
||||
return f"{prefix}\n\n{rebuilt}\n"
|
||||
return rebuilt + "\n"
|
||||
|
||||
|
||||
def load_text(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as exc:
|
||||
raise ChangelogError(f"changelog not found: {path}") from exc
|
||||
|
||||
|
||||
def write_text(path: Path, text: str) -> None:
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def cmd_prepare(args: argparse.Namespace) -> int:
|
||||
path = Path(args.path)
|
||||
original = load_text(path)
|
||||
updated = prepare_release(original, args.version, args.date)
|
||||
write_text(path, updated)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_extract(args: argparse.Namespace) -> int:
|
||||
path = Path(args.path)
|
||||
body = extract_section_body(load_text(path), args.version)
|
||||
if args.output:
|
||||
write_text(Path(args.output), body)
|
||||
else:
|
||||
sys.stdout.write(body)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Prepare and extract changelog release notes")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
prepare = subparsers.add_parser("prepare", help="Move Unreleased into a versioned section")
|
||||
prepare.add_argument("--path", default="CHANGELOG.md")
|
||||
prepare.add_argument("--version", required=True)
|
||||
prepare.add_argument("--date", default=str(date.today()))
|
||||
prepare.set_defaults(func=cmd_prepare)
|
||||
|
||||
extract = subparsers.add_parser("extract", help="Extract a version section body")
|
||||
extract.add_argument("--path", default="CHANGELOG.md")
|
||||
extract.add_argument("--version", required=True)
|
||||
extract.add_argument("--output")
|
||||
extract.set_defaults(func=cmd_extract)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
return args.func(args)
|
||||
except ChangelogError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from scripts.changelog import extract_section_body, prepare_release
|
||||
|
||||
|
||||
class ChangelogScriptTests(unittest.TestCase):
|
||||
def test_prepare_release_moves_unreleased_into_versioned_section(self) -> None:
|
||||
original = """# Changelog\n\n## Unreleased\n\n### Fixed\n- Smoothed Claude flapping.\n\n## [0.1.0] - 2026-03-27\n\n### Added\n- Initial release.\n"""
|
||||
|
||||
updated = prepare_release(original, "0.1.1", "2026-03-28")
|
||||
|
||||
self.assertIn("## Unreleased\n\n## [0.1.1] - 2026-03-28", updated)
|
||||
self.assertIn("### Fixed\n- Smoothed Claude flapping.", updated)
|
||||
self.assertIn("## [0.1.0] - 2026-03-27", updated)
|
||||
|
||||
def test_prepare_release_accepts_bracketed_unreleased_heading(self) -> None:
|
||||
original = """# Changelog\n\n## [Unreleased]\n\n### Added\n- Added sounds.\n"""
|
||||
|
||||
updated = prepare_release(original, "0.1.1", "2026-03-28")
|
||||
|
||||
self.assertIn("## Unreleased\n\n## [0.1.1] - 2026-03-28", updated)
|
||||
self.assertIn("### Added\n- Added sounds.", updated)
|
||||
|
||||
def test_extract_section_body_returns_requested_version_only(self) -> None:
|
||||
changelog = """# Changelog\n\n## Unreleased\n\n## [0.1.1] - 2026-03-28\n\n### Fixed\n- Smoothed Claude flapping.\n\n## [0.1.0] - 2026-03-27\n\n### Added\n- Initial release.\n"""
|
||||
|
||||
body = extract_section_body(changelog, "0.1.1")
|
||||
|
||||
self.assertEqual(body, "### Fixed\n- Smoothed Claude flapping.\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue