Skip to content

Commit c9a6c05

Browse files
committed
chore: improve release automation
1 parent 30f5a8a commit c9a6c05

6 files changed

Lines changed: 279 additions & 7 deletions

File tree

.github/workflows/auto_version.yml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ concurrency:
2525

2626
jobs:
2727
tag:
28-
if: github.actor != 'github-actions[bot]'
28+
if: >
29+
github.actor != 'github-actions[bot]' &&
30+
(github.event_name == 'workflow_dispatch' ||
31+
!contains(github.event.head_commit.message, 'chore(release):'))
2932
runs-on: ubuntu-latest
3033
steps:
3134
- name: Checkout
@@ -49,12 +52,17 @@ jobs:
4952
version="$(python scripts/next_version.py --channel "$channel" --write)"
5053
echo "version=$version" >> "$GITHUB_OUTPUT"
5154
55+
- name: Update Changelog
56+
shell: bash
57+
run: |
58+
python scripts/update_changelog.py --version "${{ steps.version.outputs.version }}"
59+
5260
- name: Commit and Tag
5361
shell: bash
5462
run: |
5563
git config user.name "github-actions[bot]"
5664
git config user.email "github-actions[bot]@users.noreply.github.com"
57-
git add VERSION
65+
git add VERSION CHANGELOG.md
5866
if git diff --cached --quiet; then
5967
echo "VERSION unchanged; nothing to tag."
6068
exit 0

.github/workflows/release.yml

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ on:
66
tags:
77
- "v*"
88

9+
permissions:
10+
contents: write
11+
912
jobs:
1013
build:
1114
name: build-${{ matrix.os }}
@@ -33,8 +36,18 @@ jobs:
3336
version: "6.10.1"
3437
arch: ${{ matrix.os == 'windows-latest' && 'win64_msvc2022_64' || matrix.os == 'macos-latest' && 'clang_64' || 'gcc_64' }}
3538

39+
- name: Determine Update Channel
40+
shell: bash
41+
run: |
42+
tag="${GITHUB_REF_NAME}"
43+
if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
44+
echo "PAKFU_UPDATE_CHANNEL=dev" >> "$GITHUB_ENV"
45+
else
46+
echo "PAKFU_UPDATE_CHANNEL=stable" >> "$GITHUB_ENV"
47+
fi
48+
3649
- name: Configure
37-
run: meson setup build --backend ninja -Dgithub_repo=${{ github.repository }}
50+
run: meson setup build --backend ninja -Dgithub_repo=${{ github.repository }} -Dupdate_channel=$PAKFU_UPDATE_CHANNEL
3851

3952
- name: Build
4053
run: meson compile -C build
@@ -58,13 +71,32 @@ jobs:
5871
uses: actions/upload-artifact@v4
5972
with:
6073
name: pakfu-${{ matrix.os }}
61-
path: dist/*
74+
path: |
75+
dist/*.zip
76+
dist/*.tar.gz
77+
dist/*.tgz
78+
dist/*.tar.xz
79+
dist/*.AppImage
80+
dist/*.dmg
81+
dist/*.pkg
82+
dist/*.exe
83+
dist/*.msi
6284
6385
release:
6486
name: github-release
6587
runs-on: ubuntu-latest
6688
needs: build
6789
steps:
90+
- name: Checkout
91+
uses: actions/checkout@v4
92+
with:
93+
fetch-depth: 0
94+
95+
- name: Setup Python
96+
uses: actions/setup-python@v5
97+
with:
98+
python-version: "3.x"
99+
68100
- name: Download Artifacts
69101
uses: actions/download-artifact@v4
70102
with:
@@ -81,9 +113,16 @@ jobs:
81113
echo "PAKFU_PRERELEASE=false" >> "$GITHUB_ENV"
82114
fi
83115
116+
- name: Prepare Release Notes
117+
shell: bash
118+
run: |
119+
python scripts/release_notes.py --version "${GITHUB_REF_NAME}" --output release_notes.md
120+
84121
- name: Publish Release
85122
uses: softprops/action-gh-release@v2
86123
with:
87124
files: dist/**
88-
generate_release_notes: true
125+
body_path: release_notes.md
126+
generate_release_notes: false
127+
fail_on_unmatched_files: true
89128
prerelease: ${{ env.PAKFU_PRERELEASE == 'true' }}

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Changelog
2+
3+
All notable changes to PakFu are documented here.

docs/RELEASES.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ Preview the next version locally:
3838
python scripts/next_version.py --channel dev
3939
```
4040

41+
## Changelog
42+
Release notes come from `CHANGELOG.md`, which is updated automatically during
43+
the `auto-version` workflow. Keep commit messages concise and descriptive so
44+
the notes read well.
45+
4146
## Release Assets
4247
To enable in-app updates, attach platform packages to each GitHub Release.
4348
Current packaging targets:
@@ -51,8 +56,9 @@ the updater can select the correct file automatically.
5156
## Release Workflow
5257
1. Push to `main` (default: auto-creates a **dev** prerelease).
5358
2. The `auto-version` workflow computes the next version, updates `VERSION`,
54-
commits, and tags it.
55-
3. The `release` workflow builds and publishes packages for the tag.
59+
updates `CHANGELOG.md`, commits, and tags it.
60+
3. The `release` workflow builds and publishes packages for the tag, using
61+
the matching changelog entry as release notes.
5662
4. For a stable release, run the `auto-version` workflow manually with
5763
`channel=stable`.
5864

scripts/release_notes.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import sys
6+
from pathlib import Path
7+
8+
9+
def main() -> int:
10+
parser = argparse.ArgumentParser(description="Extract release notes from CHANGELOG.md.")
11+
parser.add_argument("--version", required=True, help="Version or tag (e.g. v1.2.3).")
12+
parser.add_argument("--output", help="Write notes to this file.")
13+
args = parser.parse_args()
14+
15+
version = args.version.lstrip("vV")
16+
changelog = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
17+
if not changelog.exists():
18+
print("CHANGELOG.md not found.", file=sys.stderr)
19+
return 1
20+
21+
lines = changelog.read_text(encoding="utf-8").splitlines()
22+
start = None
23+
for idx, line in enumerate(lines):
24+
if line.startswith(f"## [{version}]"):
25+
start = idx
26+
break
27+
if start is None:
28+
print(f"Version {version} not found in CHANGELOG.md.", file=sys.stderr)
29+
return 1
30+
31+
end = len(lines)
32+
for idx in range(start + 1, len(lines)):
33+
if lines[idx].startswith("## ["):
34+
end = idx
35+
break
36+
37+
notes = "\n".join(lines[start:end]).rstrip() + "\n"
38+
39+
if args.output:
40+
Path(args.output).write_text(notes, encoding="utf-8")
41+
else:
42+
print(notes)
43+
return 0
44+
45+
46+
if __name__ == "__main__":
47+
raise SystemExit(main())

scripts/update_changelog.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import datetime as dt
6+
import re
7+
import subprocess
8+
from pathlib import Path
9+
10+
TYPE_TITLES = {
11+
"feat": "Added",
12+
"fix": "Fixed",
13+
"perf": "Performance",
14+
"refactor": "Changed",
15+
"docs": "Documentation",
16+
"build": "Build",
17+
"ci": "CI",
18+
"test": "Tests",
19+
"style": "Style",
20+
"chore": "Chore",
21+
}
22+
23+
SECTION_ORDER = [
24+
"Breaking Changes",
25+
"Added",
26+
"Fixed",
27+
"Changed",
28+
"Performance",
29+
"Documentation",
30+
"Build",
31+
"CI",
32+
"Tests",
33+
"Style",
34+
"Chore",
35+
"Other",
36+
]
37+
38+
SUBJECT_RE = re.compile(r"^(?P<type>[a-zA-Z]+)(\(.+\))?(?P<bang>!)?:\s*(?P<desc>.+)")
39+
40+
41+
def run_git(args: list[str], cwd: Path) -> str:
42+
return subprocess.check_output(["git", *args], cwd=cwd, text=True).strip()
43+
44+
45+
def last_tag(repo: Path) -> str | None:
46+
try:
47+
return run_git(["describe", "--tags", "--abbrev=0"], repo)
48+
except subprocess.CalledProcessError:
49+
return None
50+
51+
52+
def commit_log(repo: Path, base_tag: str | None) -> list[tuple[str, str]]:
53+
if base_tag:
54+
range_spec = f"{base_tag}..HEAD"
55+
else:
56+
range_spec = "HEAD"
57+
log = run_git(["log", range_spec, "--pretty=format:%s%n%b%n==END=="], repo)
58+
entries = [entry.strip() for entry in log.split("==END==") if entry.strip()]
59+
commits = []
60+
for entry in entries:
61+
lines = entry.splitlines()
62+
subject = lines[0].strip() if lines else ""
63+
body = "\n".join(lines[1:]) if len(lines) > 1 else ""
64+
commits.append((subject, body))
65+
return commits
66+
67+
68+
def normalize_subject(subject: str) -> tuple[str | None, str, bool]:
69+
if subject.startswith("Merge "):
70+
return None, "", False
71+
if subject.startswith("chore(release):"):
72+
return None, "", False
73+
match = SUBJECT_RE.match(subject)
74+
breaking = False
75+
if match:
76+
change_type = match.group("type").lower()
77+
desc = match.group("desc").strip()
78+
breaking = match.group("bang") == "!"
79+
return change_type, desc, breaking
80+
return None, subject.strip(), False
81+
82+
83+
def build_sections(commits: list[tuple[str, str]]) -> dict[str, list[str]]:
84+
sections: dict[str, list[str]] = {}
85+
for subject, body in commits:
86+
change_type, desc, breaking = normalize_subject(subject)
87+
if not desc:
88+
continue
89+
if "BREAKING CHANGE" in body:
90+
breaking = True
91+
if breaking:
92+
sections.setdefault("Breaking Changes", []).append(desc)
93+
continue
94+
title = TYPE_TITLES.get(change_type, "Other")
95+
sections.setdefault(title, []).append(desc)
96+
return sections
97+
98+
99+
def load_changelog(path: Path) -> list[str]:
100+
if not path.exists():
101+
return [
102+
"# Changelog",
103+
"",
104+
"All notable changes to PakFu are documented here.",
105+
"",
106+
]
107+
return path.read_text(encoding="utf-8").splitlines()
108+
109+
110+
def write_changelog(path: Path, lines: list[str]) -> None:
111+
text = "\n".join(lines).rstrip() + "\n"
112+
path.write_text(text, encoding="utf-8")
113+
114+
115+
def insert_entry(lines: list[str], entry: list[str]) -> list[str]:
116+
for line in lines:
117+
if line.startswith(entry[0]):
118+
return lines
119+
insert_at = len(lines)
120+
for idx, line in enumerate(lines):
121+
if line.startswith("## ["):
122+
insert_at = idx
123+
break
124+
return lines[:insert_at] + entry + [""] + lines[insert_at:]
125+
126+
127+
def main() -> int:
128+
parser = argparse.ArgumentParser(description="Update CHANGELOG.md from git history.")
129+
parser.add_argument(
130+
"--version",
131+
help="Version to add (defaults to VERSION file).",
132+
)
133+
parser.add_argument(
134+
"--date",
135+
help="Release date (YYYY-MM-DD). Defaults to UTC today.",
136+
)
137+
args = parser.parse_args()
138+
139+
repo = Path(__file__).resolve().parent.parent
140+
version_file = repo / "VERSION"
141+
version = args.version or version_file.read_text(encoding="utf-8").strip()
142+
version = version.lstrip("vV")
143+
date = args.date or dt.datetime.utcnow().date().isoformat()
144+
145+
base_tag = last_tag(repo)
146+
commits = commit_log(repo, base_tag)
147+
sections = build_sections(commits)
148+
149+
entry = [f"## [{version}] - {date}"]
150+
if not sections:
151+
entry += ["### Changed", "- No user-facing changes."]
152+
else:
153+
for title in SECTION_ORDER:
154+
items = sections.get(title, [])
155+
if not items:
156+
continue
157+
entry.append(f"### {title}")
158+
for item in items:
159+
entry.append(f"- {item}")
160+
161+
changelog_path = repo / "CHANGELOG.md"
162+
lines = load_changelog(changelog_path)
163+
updated = insert_entry(lines, entry)
164+
write_changelog(changelog_path, updated)
165+
return 0
166+
167+
168+
if __name__ == "__main__":
169+
raise SystemExit(main())

0 commit comments

Comments
 (0)