Skip to content

Commit 503a5c8

Browse files
committed
Add process for generating changelog during CD pipeline
1 parent 9365042 commit 503a5c8

5 files changed

Lines changed: 270 additions & 3 deletions

File tree

.github/workflows/cd.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,9 +348,21 @@ jobs:
348348
env:
349349
GITHUB_ACTOR: ${{ github.actor }}
350350
run: |
351-
uv run add-git-credentials
352-
uv run git-switch-to-main-branch
353-
uv run git-refresh-current-branch
351+
uv run add-git-credentials
352+
uv run git-switch-to-main-branch
353+
uv run git-refresh-current-branch
354+
355+
- name: Build ChangeLog
356+
id: build-changelog
357+
run: uv run build-changelog
358+
359+
- name: Commit ChangeLog
360+
id: commit-changelog
361+
run: |
362+
git add .
363+
git commit --message "Update changelog to \`${VERSION}\` [skip ci]" || echo "No changes to commit"
364+
git push --force --no-verify
365+
git status
354366
355367
- name: Build docs
356368
id: build-docs

docs/usage/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
--8<-- "CHANGELOG.md"

mkdocs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ nav:
4747
- Home: index.md
4848
- Usage:
4949
- Overview: usage/overview.md
50+
- Change Log: usage/changelog.md
5051
- Modules:
5152
- code/index.md
5253
- Classes: code/classes.md

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ docs-delete-version = "utils.scripts:docs_delete_version"
8282
docs-set-default = "utils.scripts:docs_set_default"
8383
build-static-docs = "utils.scripts:build_static_docs"
8484
build-versioned-docs = "utils.scripts:build_versioned_docs"
85+
generate-changelog = "utils.changelog:main"
8586

8687
[dependency-groups]
8788
dev = [
@@ -108,6 +109,7 @@ docs = [
108109
"mkdocs-material==9.*",
109110
"mkdocstrings==0.*",
110111
"mkdocstrings-python==1.*",
112+
"pygithub==2.*",
111113
]
112114
test = [
113115
"mypy==1.*",

src/utils/changelog.py

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
# ============================================================================ #
2+
# #
3+
# Title: Generate Changelog for GitHub Repository #
4+
# Purpose: Generate a changelog for a GitHub repository by fetching #
5+
# release tags and their associated commit messages. #
6+
# #
7+
# ============================================================================ #
8+
9+
10+
# ---------------------------------------------------------------------------- #
11+
# #
12+
# Setup ####
13+
# #
14+
# ---------------------------------------------------------------------------- #
15+
16+
17+
## --------------------------------------------------------------------------- #
18+
## Imports ####
19+
## --------------------------------------------------------------------------- #
20+
21+
22+
# ## Python StdLib Imports ----
23+
import os
24+
import re
25+
from pathlib import Path
26+
27+
# ## Python Third Party Imports ----
28+
from github import Auth, Github
29+
from github.Auth import Token
30+
from github.Commit import Commit
31+
from github.GithubObject import NotSet
32+
from github.GitRelease import GitRelease
33+
from github.Repository import Repository
34+
35+
36+
## --------------------------------------------------------------------------- #
37+
## Constants ####
38+
## --------------------------------------------------------------------------- #
39+
40+
41+
OUTPUT_FILENAME: str = "CHANGELOG.md"
42+
OUTPUT_FILEPATH: Path = Path(OUTPUT_FILENAME)
43+
TOKEN: str = os.environ["GITHUB_TOKEN"]
44+
AUTH: Token = Auth.Token(TOKEN)
45+
NEW_LINE: str = "\n"
46+
BLANK_LINE: str = "\n\n"
47+
LINE_BREAK: str = "<br>"
48+
TAB: str = " "
49+
50+
51+
# ---------------------------------------------------------------------------- #
52+
# #
53+
# Functions ####
54+
# #
55+
# ---------------------------------------------------------------------------- #
56+
57+
58+
## --------------------------------------------------------------------------- #
59+
## Output ####
60+
## --------------------------------------------------------------------------- #
61+
62+
63+
def prepare_output_file() -> None:
64+
"""
65+
Prepare the output file by deleting it if it exists and creating a new one.
66+
This ensures that the output file is always fresh and does not contain any
67+
old data from previous runs.
68+
"""
69+
# NOTE: We want to re-create the output file every time we run the script,
70+
# so here we will delete it even if it does exist and generate a whole new one.
71+
72+
# Delete the output file if it exists
73+
if OUTPUT_FILEPATH.exists():
74+
OUTPUT_FILEPATH.unlink()
75+
76+
# Create a new output file
77+
OUTPUT_FILEPATH.touch()
78+
79+
80+
def add_page_styling() -> str:
81+
"""
82+
Generate the page styling for the changelog.
83+
This function returns a string containing the CSS styles to be applied to the changelog page.
84+
The styles are used to hide the nested navigation lists in the Markdown navigation.
85+
"""
86+
return (
87+
f"<style>{NEW_LINE}"
88+
f".md-nav--secondary .md-nav__list .md-nav__item {{ display: block; padding-bottom: 0.5em; }}{NEW_LINE}"
89+
f".md-nav--secondary .md-nav__list .md-nav__list {{ display: none; }}{NEW_LINE}"
90+
f"</style>{BLANK_LINE}"
91+
)
92+
93+
94+
def add_header(repo: Repository) -> str:
95+
"""
96+
Generate the header for the changelog.
97+
This function returns a string containing the header information, including the repository name,
98+
origin URL, and API URL. The header is formatted as HTML comments to provide metadata about the changelog.
99+
"""
100+
return (
101+
f"<!-- !This file is auto-generated. Do not edit this file manually! -->{NEW_LINE}"
102+
f"{NEW_LINE}"
103+
f"<!-- repo='{repo.full_name}' -->{NEW_LINE}"
104+
f"<!-- origin_url='{repo.html_url}' -->{NEW_LINE}"
105+
f"<!-- api_url='{repo.url}' -->{NEW_LINE}"
106+
f"{NEW_LINE}"
107+
)
108+
109+
110+
def add_release_info(release: GitRelease, repo: Repository) -> str:
111+
"""
112+
Generate the release information for a given GitHub release.
113+
This function returns a string containing the formatted release information,
114+
including the release tag, date, link, and body.
115+
"""
116+
return (
117+
f'!!! info "{release.tag_name}"{NEW_LINE}'
118+
f"{NEW_LINE}"
119+
f"{TAB}## **{release.title}**{BLANK_LINE}"
120+
f"{TAB}<!-- md:tag {release.tag_name} -->{LINE_BREAK}{NEW_LINE}"
121+
f"{TAB}<!-- md:date {release.created_at.date()} -->{LINE_BREAK}{NEW_LINE}"
122+
f"{TAB}<!-- md:link [{repo.full_name}/releases/tag/{release.tag_name}]({release.html_url}) -->{BLANK_LINE}"
123+
)
124+
125+
126+
def add_release_notes(release: GitRelease) -> str:
127+
"""
128+
Generate the release notes for a given GitHub release.
129+
This function returns a string containing the formatted release notes,
130+
including the release body and any additional information.
131+
"""
132+
return (
133+
f'{TAB}??? note "Release Notes"{BLANK_LINE}'
134+
f"{TAB * 2}{release.body.replace(f'{BLANK_LINE}', NEW_LINE).replace('## ', '### ').replace(NEW_LINE, f'{TAB * 2}')}{BLANK_LINE}"
135+
)
136+
137+
138+
def add_commit_info(commit: Commit) -> str:
139+
"""
140+
Generate the commit information for a given GitHub commit.
141+
This function returns a string containing the formatted commit information,
142+
including the commit message, author, and link to the commit.
143+
"""
144+
# NOTE: We write the commit message to the output file.
145+
# We format the commit message to replace newlines with `{LINE_BREAK}` tags for better readability in Markdown.
146+
# We also include the author's login and a link to their GitHub profile, as well as a link to the commit itself.
147+
return (
148+
f"{TAB * 2}* {commit.commit.message.replace(f'{NEW_LINE * 2}', NEW_LINE).replace(NEW_LINE, f'{LINE_BREAK}{NEW_LINE}{TAB * 3}')}"
149+
f" (by [{commit.author.login if commit.author else ''}]({commit.author.html_url if commit.author else ''}))"
150+
f" [View]({commit.html_url}){NEW_LINE * 2}"
151+
)
152+
153+
154+
# ---------------------------------------------------------------------------- #
155+
# #
156+
# Main Section ####
157+
# #
158+
# ---------------------------------------------------------------------------- #
159+
160+
161+
def main() -> None:
162+
"""
163+
Main function to generate the changelog for the GitHub repository.
164+
It prepares the output file, connects to GitHub, fetches the releases,
165+
and writes the changelog to the output file.
166+
"""
167+
168+
# NOTE: The reason why we use a context manager here is to ensure that the GitHub connection is properly terminated and the file is properly closed after we are finished processing; even if an error occurs during the process.
169+
# Also, here we can also open both contexts in the same line; which is syntactically cleaner and more efficient.
170+
171+
### Open the contexts ----
172+
with Github(auth=AUTH) as g, open(OUTPUT_FILENAME, "w") as f:
173+
174+
# NOTE: The repository is hardcoded here, but you can modify it to fetch from an environment variable or a configuration file if needed. This is done to ensure that the script is self-contained and does not require external configuration.
175+
176+
### Get the repository ----
177+
REPO: Repository = g.get_repo("data-science-extensions/toolbox-python")
178+
179+
### Write the header to the output file ----
180+
f.write(add_header(REPO))
181+
182+
### Prepare the output file ----
183+
f.write(add_page_styling())
184+
185+
### Fetch the releases for the repository, sorted by reverse creation date ----
186+
releases: list[GitRelease] = sorted(
187+
REPO.get_releases(),
188+
key=lambda r: r.created_at,
189+
reverse=True,
190+
)
191+
192+
### Loop through the releases ----
193+
for index, release in enumerate(releases):
194+
195+
# NOTE: We need to determine if the previous tag exists.
196+
# If `index + 1 < len(releases)`, then we can fetch the previous release's tag name.
197+
# Otherwise, we set the previous tag to `"0"` to indicate that there is no previous tag.
198+
# This is done to ensure that we can fetch the commits between the current release and the previous release.
199+
# If there is no previous release, we fetch all commits until the current release. This is the case for the very first release in the repo.
200+
201+
### Determine the previous tag if it exists, otherwise set it to "0"
202+
previous_tag: str = (
203+
releases[index + 1].tag_name if index + 1 < len(releases) else "0"
204+
)
205+
206+
### Write the release information to the output file ----
207+
f.write(add_release_info(release, REPO))
208+
209+
### Add a section for release notes ----
210+
f.write(add_release_notes(release))
211+
212+
### Add a section for updates ----
213+
f.write(f'{TAB}??? abstract "Updates"{NEW_LINE * 2}')
214+
215+
# NOTE: We fetch the commits between the current release and the previous release.
216+
# If the previous tag is "0", we fetch all commits until the current release.
217+
# Otherwise, we fetch commits since the previous release's creation date until the current release's creation date.
218+
219+
### Fetch the commits for the current release ----
220+
commits: list[Commit] = sorted(
221+
REPO.get_commits(
222+
since=(
223+
releases[index + 1].created_at
224+
if previous_tag != "0"
225+
else NotSet
226+
),
227+
until=release.created_at,
228+
),
229+
key=lambda c: c.commit.committer.date,
230+
reverse=True,
231+
)
232+
233+
### Loop through the commits ----
234+
for commit in commits:
235+
236+
# NOTE: We skip commits that are not relevant for the changelog.
237+
# Specifically, we skip merge commits, commits that update the coverage report, and commits that update the changelog itself.
238+
# This is done to ensure that the changelog only contains relevant changes made to the codebase.
239+
240+
### Skip irrelevant commits ----
241+
if re.search(
242+
r"Merge|Bump|Update coverage report|Update changelog",
243+
commit.commit.message,
244+
):
245+
continue
246+
247+
### Write the commit message to the output file ----
248+
f.write(add_commit_info(commit))
249+
250+
### Add a newline after each release section ----
251+
f.write(f"{NEW_LINE * 2}")

0 commit comments

Comments
 (0)