Skip to content

Commit 886baa3

Browse files
author
Maksym Shtembuliak
committed
Improve CI and release flow
1 parent a94f648 commit 886baa3

8 files changed

Lines changed: 267 additions & 5 deletions

File tree

‎.github/ISSUE_TEMPLATE/release_checklist.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ assignees: groove-max
88

99
## Version And Metadata
1010

11-
- [ ] Confirm the release scope and changelog summary.
11+
- [ ] Update `CHANGELOG.md` and confirm the release scope is reflected in the target version section.
1212
- [ ] Verify the version in `custom_components/eybond_local/manifest.json`.
1313
- [ ] Verify `hacs.json`, `README.md`, and public docs still reflect current installation/support guidance.
1414
- [ ] Verify `LICENSE` and repository metadata still match the intended publication state.
@@ -21,6 +21,7 @@ assignees: groove-max
2121

2222
## Release Notes
2323

24+
- [ ] Render release notes from `CHANGELOG.md` with `python3 tools/render_release_notes.py vX.Y.Z --output .local/release-notes/vX.Y.Z.md`.
2425
- [ ] Summarize user-visible changes.
2526
- [ ] Call out any breaking changes or required reconfiguration.
2627
- [ ] Mention newly supported hardware, controls, or diagnostics if applicable.
@@ -31,7 +32,7 @@ assignees: groove-max
3132
- [ ] Create or verify the Git tag.
3233
- [ ] Publish the GitHub release.
3334
- [ ] Make sure the release title and tag are aligned.
34-
- [ ] Paste the release notes into the GitHub release body.
35+
- [ ] Use the rendered release notes file as the GitHub release body.
3536

3637
## Post-Release
3738

‎.github/workflows/validate.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
runs-on: ubuntu-latest
2626
steps:
2727
- name: Check out repository
28-
uses: actions/checkout@v4
28+
uses: actions/checkout@v6
2929
- name: Hassfest validation
3030
uses: home-assistant/actions/hassfest@master
3131

@@ -34,9 +34,9 @@ jobs:
3434
runs-on: ubuntu-latest
3535
steps:
3636
- name: Check out repository
37-
uses: actions/checkout@v4
37+
uses: actions/checkout@v6
3838
- name: Set up Python
39-
uses: actions/setup-python@v5
39+
uses: actions/setup-python@v6
4040
with:
4141
python-version: "3.12"
4242
- name: Run project quality gate

‎CHANGELOG.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented in this file.
4+
5+
The format is inspired by Keep a Changelog, with one practical rule for this repository:
6+
the GitHub release body should be rendered from the matching version section here.
7+
8+
## [Unreleased]
9+
10+
### Added
11+
12+
- Add new user-visible features here.
13+
14+
### Changed
15+
16+
- Add behavior changes, refactors, or support expansions here.
17+
18+
### Fixed
19+
20+
- Add bug fixes and regressions here.
21+
22+
### Docs
23+
24+
- Add documentation-only changes here.
25+
26+
## [0.1.43] - 2026-04-15
27+
28+
### Added
29+
30+
- First public GitHub release of EyeBond Local.
31+
- Built-in local support for SMG / Modbus and PI30-family collectors, plus experimental PI18 replay coverage.
32+
- Support Archive export workflow for unsupported or partially supported inverters.
33+
- SMG writable and readback coverage for registers `341`, `342`, and `343`.
34+
35+
### Changed
36+
37+
- Config-flow runtime copy now loads from private `flow_translations/` bundles while Home Assistant-validated translation files remain Hassfest-compatible.
38+
- Public release validation now passes HACS Validation, Hassfest, and the repository quality gate in GitHub Actions.
39+
40+
### Fixed
41+
42+
- Publication blockers around config schema exposure, manifest ordering, and unsupported translation key placement.
43+
44+
### Docs
45+
46+
- Public README and release metadata were aligned for the first published release.

‎docs/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Start with the [Contributing guide](../CONTRIBUTING.md) for the project philosop
2222
Then dive into one of:
2323

2424
- [Adding A New Driver / Profile](ADDING_DRIVERS.md) — fixture-first workflow for extending hardware support
25+
- [Releasing](RELEASING.md) — changelog-first release flow and GitHub release notes generation
2526
- [Tools And CLI Scripts](../tools/README.md) — probing, fixtures, validation, doc generation
2627

2728
## I'm reviewing project status

‎docs/RELEASING.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Releasing
2+
3+
This project uses a changelog-first release flow.
4+
5+
The source of truth for release notes is [CHANGELOG.md](../CHANGELOG.md), not the GitHub release form.
6+
7+
## Release Workflow
8+
9+
1. Keep incoming work under `## [Unreleased]` in [CHANGELOG.md](../CHANGELOG.md).
10+
2. When cutting a release, move the relevant notes into a new version section whose number matches [custom_components/eybond_local/manifest.json](../custom_components/eybond_local/manifest.json).
11+
3. Run the public validation gate:
12+
13+
```bash
14+
python3 tools/quality_gate.py
15+
```
16+
17+
4. If public generated docs changed, refresh and commit them before tagging:
18+
19+
```bash
20+
python3 tools/quality_gate.py --refresh-generated
21+
```
22+
23+
5. Render the GitHub release body from the matching changelog section:
24+
25+
```bash
26+
python3 tools/render_release_notes.py v0.1.43 --output .local/release-notes/v0.1.43.md
27+
```
28+
29+
6. Create and push the tag:
30+
31+
```bash
32+
git tag v0.1.43
33+
git push origin v0.1.43
34+
```
35+
36+
7. Publish the GitHub release from the rendered notes:
37+
38+
```bash
39+
gh release create v0.1.43 \
40+
--title "v0.1.43" \
41+
--notes-file .local/release-notes/v0.1.43.md
42+
```
43+
44+
## Writing Good Release Notes
45+
46+
- Keep release notes user-facing.
47+
- Prefer grouped bullets under `Added`, `Changed`, `Fixed`, and `Docs`.
48+
- Mention breaking changes or required reconfiguration explicitly.
49+
- Call out newly supported hardware, controls, diagnostics, or important workflow changes.
50+
- Keep purely internal refactors out unless they affect users, maintainers, or release safety.
51+
52+
## Maintainer Checklist
53+
54+
The maintainer checklist lives in [.github/ISSUE_TEMPLATE/release_checklist.md](../.github/ISSUE_TEMPLATE/release_checklist.md).

‎tests/test_render_release_notes.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
from pathlib import Path
5+
import textwrap
6+
import unittest
7+
8+
9+
REPO_ROOT = Path(__file__).resolve().parents[1]
10+
SCRIPT_PATH = REPO_ROOT / "tools" / "render_release_notes.py"
11+
12+
_SPEC = importlib.util.spec_from_file_location("render_release_notes", SCRIPT_PATH)
13+
if _SPEC is None or _SPEC.loader is None:
14+
raise RuntimeError("Could not load render_release_notes tool")
15+
_MODULE = importlib.util.module_from_spec(_SPEC)
16+
_SPEC.loader.exec_module(_MODULE)
17+
18+
19+
class RenderReleaseNotesTests(unittest.TestCase):
20+
def test_normalize_version_accepts_tag_prefix(self) -> None:
21+
self.assertEqual(_MODULE.normalize_version("v0.1.43"), "0.1.43")
22+
self.assertEqual(_MODULE.normalize_version("0.1.43"), "0.1.43")
23+
24+
def test_extract_release_notes_returns_requested_section_body(self) -> None:
25+
changelog = textwrap.dedent(
26+
"""\
27+
# Changelog
28+
29+
## [Unreleased]
30+
31+
### Added
32+
33+
- Future work.
34+
35+
## [0.1.43] - 2026-04-15
36+
37+
### Added
38+
39+
- First public release.
40+
41+
### Fixed
42+
43+
- Workflow validation.
44+
45+
## [0.1.42] - 2026-04-14
46+
47+
### Fixed
48+
49+
- Earlier fix.
50+
"""
51+
)
52+
53+
rendered = _MODULE.extract_release_notes(changelog, "v0.1.43")
54+
55+
self.assertEqual(
56+
rendered,
57+
"### Added\n\n- First public release.\n\n### Fixed\n\n- Workflow validation.\n",
58+
)
59+
60+
def test_extract_release_notes_raises_for_missing_version(self) -> None:
61+
changelog = "# Changelog\n\n## [0.1.43] - 2026-04-15\n\n### Added\n\n- First public release.\n"
62+
63+
with self.assertRaisesRegex(ValueError, "Version 0.1.44"):
64+
_MODULE.extract_release_notes(changelog, "0.1.44")
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()

‎tools/README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,22 @@ Refresh generated docs first, then run the quality gate:
126126
python3 tools/quality_gate.py --refresh-generated
127127
```
128128

129+
## I want to cut a release
130+
131+
Render the GitHub release body for a version directly from `CHANGELOG.md`:
132+
133+
```bash
134+
python3 tools/render_release_notes.py v0.1.43
135+
```
136+
137+
Write the release body to a file for `gh release create --notes-file`:
138+
139+
```bash
140+
python3 tools/render_release_notes.py v0.1.43 --output .local/release-notes/v0.1.43.md
141+
```
142+
143+
The full maintainer flow is documented in [docs/RELEASING.md](../docs/RELEASING.md).
144+
129145
---
130146

131147
## I want to refresh generated documentation

‎tools/render_release_notes.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#!/usr/bin/env python3
2+
"""Render GitHub release notes for one version from CHANGELOG.md."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
from pathlib import Path
8+
import re
9+
import sys
10+
11+
12+
REPO_ROOT = Path(__file__).resolve().parents[1]
13+
CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md"
14+
VERSION_HEADER_RE = re.compile(r"^## \[(?P<version>[^\]]+)\](?: - .+)?$", re.MULTILINE)
15+
16+
17+
def normalize_version(version: str) -> str:
18+
normalized = version.strip()
19+
if normalized.startswith("v"):
20+
normalized = normalized[1:]
21+
return normalized
22+
23+
24+
def extract_release_notes(changelog_text: str, version: str) -> str:
25+
target = normalize_version(version)
26+
matches = list(VERSION_HEADER_RE.finditer(changelog_text))
27+
28+
for index, match in enumerate(matches):
29+
if normalize_version(match.group("version")) != target:
30+
continue
31+
32+
start = match.end()
33+
end = matches[index + 1].start() if index + 1 < len(matches) else len(changelog_text)
34+
body = changelog_text[start:end].strip()
35+
if not body:
36+
raise ValueError(f"Changelog section for version {target} is empty")
37+
return body + "\n"
38+
39+
raise ValueError(f"Version {target} was not found in {CHANGELOG_PATH.name}")
40+
41+
42+
def main() -> int:
43+
parser = argparse.ArgumentParser(description=__doc__)
44+
parser.add_argument("version", help="version or tag, for example 0.1.43 or v0.1.43")
45+
parser.add_argument(
46+
"--changelog",
47+
default=str(CHANGELOG_PATH),
48+
help="path to the changelog file",
49+
)
50+
parser.add_argument(
51+
"--output",
52+
help="optional output file path; prints to stdout when omitted",
53+
)
54+
args = parser.parse_args()
55+
56+
changelog_path = Path(args.changelog).expanduser()
57+
try:
58+
changelog_text = changelog_path.read_text(encoding="utf-8")
59+
rendered = extract_release_notes(changelog_text, args.version)
60+
except (OSError, ValueError) as exc:
61+
print(str(exc), file=sys.stderr)
62+
return 1
63+
64+
if args.output:
65+
output_path = Path(args.output).expanduser()
66+
output_path.parent.mkdir(parents=True, exist_ok=True)
67+
output_path.write_text(rendered, encoding="utf-8")
68+
print(output_path)
69+
return 0
70+
71+
print(rendered, end="")
72+
return 0
73+
74+
75+
if __name__ == "__main__":
76+
raise SystemExit(main())

0 commit comments

Comments
 (0)