Skip to content

Commit 6d30383

Browse files
author
Максим Лясковский
committed
ci: add CHANGELOG.md and auto-prepend new sections on release
1 parent 67b49e8 commit 6d30383

4 files changed

Lines changed: 178 additions & 12 deletions

File tree

.github/workflows/release.yml

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
name: release
22

3-
# Trigger model: this workflow is triggered ONLY by manual dispatch.
3+
# Trigger model: this workflow runs ONLY by manual dispatch.
44
# Click "Run workflow" in the Actions tab on GitHub.com (or run
55
# `gh workflow run release.yml -f version=2.0.1`
66
# from the CLI), enter the new version, and the workflow will:
7-
# 1. Bump version files
8-
# 2. Commit + tag + push to main
9-
# 3. Run the test suite
10-
# 4. Build LinkBridge.app
11-
# 5. Package as a zip with ditto
12-
# 6. Create a draft GitHub Release with auto-generated notes
7+
# 1. Run the test suite
8+
# 2. Bump version files (linkbridge/__init__.py, setup.py)
9+
# 3. Generate release notes from PRs merged since the previous tag
10+
# 4. Prepend the new section to CHANGELOG.md
11+
# 5. Commit (version bump + changelog) + tag + push to main
12+
# 6. Build LinkBridge.app
13+
# 7. Package as a zip with ditto
14+
# 8. Create a draft GitHub Release with the same notes and the .zip
1315
on:
1416
workflow_dispatch:
1517
inputs:
@@ -30,7 +32,8 @@ jobs:
3032
uses: actions/checkout@v4
3133
with:
3234
ref: main
33-
fetch-depth: 0 # full history so auto-generated release notes can diff against the previous tag
35+
fetch-depth: 0 # full history so we can see previous tags
36+
fetch-tags: true # explicitly pull tags so `git describe` works
3437

3538
- name: Configure git identity
3639
run: |
@@ -54,12 +57,43 @@ jobs:
5457
- name: Bump version files
5558
run: venv/bin/bump-my-version replace --new-version "${{ inputs.version }}"
5659

57-
- name: Show diff after bump
60+
- name: Show diff after version bump
5861
run: git diff
5962

63+
- name: Generate release notes from previous tag
64+
env:
65+
GH_TOKEN: ${{ github.token }}
66+
run: |
67+
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
68+
echo "previous tag: ${PREV_TAG:-<none>}"
69+
70+
if [[ -z "$PREV_TAG" ]]; then
71+
gh api -X POST "repos/${{ github.repository }}/releases/generate-notes" \
72+
-f tag_name="v${{ inputs.version }}" \
73+
-f target_commitish="main" \
74+
--jq '.body' > release-notes.md
75+
else
76+
gh api -X POST "repos/${{ github.repository }}/releases/generate-notes" \
77+
-f tag_name="v${{ inputs.version }}" \
78+
-f previous_tag_name="$PREV_TAG" \
79+
-f target_commitish="main" \
80+
--jq '.body' > release-notes.md
81+
fi
82+
83+
echo "--- generated notes ---"
84+
cat release-notes.md
85+
echo "--- end notes ---"
86+
87+
- name: Prepend new section to CHANGELOG.md
88+
run: |
89+
venv/bin/python scripts/update_changelog.py \
90+
"${{ inputs.version }}" \
91+
"$(date -u +%Y-%m-%d)" \
92+
release-notes.md
93+
6094
- name: Commit, tag, push
6195
run: |
62-
git add linkbridge/__init__.py setup.py
96+
git add linkbridge/__init__.py setup.py CHANGELOG.md
6397
git commit -m "chore: release v${{ inputs.version }}"
6498
git tag -a "v${{ inputs.version }}" -m "Release v${{ inputs.version }}"
6599
git push origin main
@@ -81,6 +115,6 @@ jobs:
81115
gh release create "v${{ inputs.version }}" \
82116
--repo "${{ github.repository }}" \
83117
--title "LinkBridge v${{ inputs.version }}" \
84-
--generate-notes \
118+
--notes-file release-notes.md \
85119
--draft \
86120
"dist/LinkBridge-v${{ inputs.version }}.zip"

CHANGELOG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Changelog
2+
3+
All notable changes to LinkBridge are documented here, in reverse
4+
chronological order. From v2.0.1 onwards, new entries are appended
5+
automatically by the [`release.yml`](.github/workflows/release.yml)
6+
workflow on each release — they list every pull request merged since
7+
the previous version. The v2.0.0 entry below was hand-written because
8+
it predates the automation.
9+
10+
The format roughly follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
11+
and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12+
13+
<!-- changelog-insertion-point -->
14+
15+
## [v2.0.0](https://github.com/AnyByte/LinkBridge/releases/tag/v2.0.0) — 2026-04-09 — macOS port
16+
17+
Initial macOS port release. Replaces the original Linux/ALSA implementation
18+
with a pure-Python macOS menu bar app. The Linux sources are archived under
19+
`legacy/` and are no longer maintained.
20+
21+
### What's Changed
22+
23+
- Merge macOS port ([#1](https://github.com/AnyByte/LinkBridge/pull/1)) — full rewrite for macOS, packaged as `LinkBridge.app`
24+
25+
### Highlights
26+
27+
- Pure-Python menu bar app built on `rumps` (no native code of our own)
28+
- Bidirectional MIDI via `mido` + `python-rtmidi` over CoreMIDI
29+
- 24 ppqn clock generator with drift-compensated absolute-time scheduling
30+
- Reactive Ableton Link integration via `aalink` callbacks (no polling)
31+
- Settings persistence at `~/Library/Application Support/LinkBridge/settings.json`
32+
- Distributed as a double-clickable `LinkBridge.app` bundle (custom icon, `LSUIElement`)
33+
- 22 unit tests covering Settings, MidiOutput, and ClockEngine
34+
- py2app build via `./scripts/build_app.sh`
35+
- GitHub Actions CI: tests on every PR, releases via `workflow_dispatch`
36+
37+
### Compatibility
38+
39+
Works with any host that broadcasts Ableton Link tempo: djay Pro, Mixxx,
40+
Ableton Live, Logic Pro, GarageBand. **Rekordbox 7.x** has a one-way Link
41+
integration, so manual tempo entry in the Link sub-window is required —
42+
see the [README](README.md) for the workflow.

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,11 @@ gh run watch # follow the build
233233
```
234234

235235
The release workflow uses [`bump-my-version`](https://github.com/callowayproject/bump-my-version)
236-
under the hood; the file list is in `pyproject.toml`.
236+
under the hood (file list in `pyproject.toml`) and prepends each new
237+
release section to [`CHANGELOG.md`](CHANGELOG.md) via
238+
`scripts/update_changelog.py`. The changelog entries are auto-generated
239+
from the pull requests merged since the previous tag using GitHub's
240+
release-notes API.
237241

238242
## Regenerating the app icon
239243

scripts/update_changelog.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python3
2+
"""Prepend a new release section to CHANGELOG.md.
3+
4+
Reads release notes from a markdown file (typically generated by GitHub's
5+
`/repos/{owner}/{repo}/releases/generate-notes` API) and inserts them as a
6+
new section in CHANGELOG.md, immediately after the
7+
`<!-- changelog-insertion-point -->` marker. The marker stays in place so
8+
the next release can insert above the previous one, keeping CHANGELOG.md
9+
in reverse chronological order.
10+
11+
Usage:
12+
update_changelog.py <version> <date> <notes-file>
13+
14+
Example:
15+
update_changelog.py 2.0.1 2026-05-01 release-notes.md
16+
17+
Behavior:
18+
- Errors out if CHANGELOG.md doesn't exist or has no insertion marker
19+
- Errors out if a section for this version already exists (idempotency
20+
guard so the workflow can't accidentally double-write the changelog)
21+
- Wraps the version in a markdown link to the GitHub release page so
22+
readers can jump straight to the release for downloads
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import sys
28+
from pathlib import Path
29+
30+
REPO = Path(__file__).resolve().parent.parent
31+
CHANGELOG = REPO / "CHANGELOG.md"
32+
INSERTION_MARKER = "<!-- changelog-insertion-point -->"
33+
RELEASES_URL = "https://github.com/AnyByte/LinkBridge/releases/tag"
34+
35+
36+
def main() -> int:
37+
if len(sys.argv) != 4:
38+
print(__doc__, file=sys.stderr)
39+
return 2
40+
41+
version, date, notes_path = sys.argv[1], sys.argv[2], Path(sys.argv[3])
42+
43+
if not CHANGELOG.exists():
44+
print(f"error: {CHANGELOG} not found", file=sys.stderr)
45+
return 1
46+
if not notes_path.exists():
47+
print(f"error: notes file {notes_path} not found", file=sys.stderr)
48+
return 1
49+
50+
notes = notes_path.read_text(encoding="utf-8").strip()
51+
if not notes:
52+
print(f"error: notes file {notes_path} is empty", file=sys.stderr)
53+
return 1
54+
55+
changelog = CHANGELOG.read_text(encoding="utf-8")
56+
57+
if INSERTION_MARKER not in changelog:
58+
print(
59+
f"error: insertion marker {INSERTION_MARKER!r} not found in {CHANGELOG}",
60+
file=sys.stderr,
61+
)
62+
return 1
63+
64+
section_header = f"## [v{version}]({RELEASES_URL}/v{version}) — {date}"
65+
if section_header in changelog:
66+
print(
67+
f"error: section for v{version} already exists in {CHANGELOG}",
68+
file=sys.stderr,
69+
)
70+
return 1
71+
72+
new_section = f"\n\n{section_header}\n\n{notes}\n"
73+
74+
updated = changelog.replace(
75+
INSERTION_MARKER,
76+
f"{INSERTION_MARKER}{new_section}",
77+
1,
78+
)
79+
80+
CHANGELOG.write_text(updated, encoding="utf-8")
81+
print(f"prepended v{version} section to {CHANGELOG.name}")
82+
return 0
83+
84+
85+
if __name__ == "__main__":
86+
raise SystemExit(main())

0 commit comments

Comments
 (0)