Skip to content

Commit c5581bb

Browse files
authored
Add CHANGES.md update automation using GitHub Actions (#686)
1 parent a95d7da commit c5581bb

2 files changed

Lines changed: 269 additions & 1 deletion

File tree

.github/workflows/publish_release.yml

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,26 @@ name: Publish PyPI Release
22

33
on:
44
release:
5-
types: [ created ]
5+
types: [ created, edited ]
6+
workflow_dispatch:
7+
inputs:
8+
tag:
9+
description: 'Release tag to (re)generate the changelog PR for. Leave empty for the latest release.'
10+
required: false
11+
default: ''
12+
force:
13+
description: 'Overwrite the section in CHANGES.md if this version already exists.'
14+
type: boolean
15+
required: false
16+
default: false
617

718
permissions:
819
id-token: write
920
contents: read
1021

1122
jobs:
1223
publish_release:
24+
if: github.event.action == 'created'
1325
runs-on: ubuntu-latest
1426

1527
steps:
@@ -50,3 +62,75 @@ jobs:
5062

5163
- name: Publish a Python distribution to PyPI.
5264
uses: pypa/gh-action-pypi-publish@release/v1
65+
66+
update_changelog:
67+
runs-on: ubuntu-latest
68+
69+
permissions:
70+
contents: write
71+
pull-requests: write
72+
73+
env:
74+
POETRY_VIRTUALENVS_IN_PROJECT: "true"
75+
76+
steps:
77+
- name: Checkout repository.
78+
uses: actions/checkout@v5
79+
80+
- name: Set up Python.
81+
id: setup-python
82+
uses: actions/setup-python@v6
83+
with:
84+
python-version: '3.9'
85+
86+
- name: Load cached Poetry installation.
87+
id: cached-poetry
88+
uses: actions/cache@v5
89+
with:
90+
path: ~/.local
91+
key: poetry-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-2.2.1 # bump the suffix to reset the cache
92+
93+
- name: Setup Poetry.
94+
if: steps.cached-poetry.outputs.cache-hit != 'true'
95+
uses: snok/install-poetry@v1
96+
with:
97+
version: 2.2.1
98+
99+
- name: Add Poetry to PATH.
100+
run: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
101+
102+
- name: Load cached venv.
103+
id: cached-venv
104+
# "full" prefix shares the venv with codegen.yml and update_lexicons.yml (same full install).
105+
uses: actions/cache@v5
106+
with:
107+
path: .venv
108+
key: venv-full-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}
109+
110+
- name: Install dependencies.
111+
if: steps.cached-venv.outputs.cache-hit != 'true'
112+
run: poetry install --no-interaction
113+
114+
- name: Update CHANGES.md from the release notes.
115+
id: changelog
116+
env:
117+
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
118+
RELEASE_DATE: ${{ github.event.release.published_at }}
119+
RELEASE_BODY: ${{ github.event.release.body }}
120+
FORCE: ${{ inputs.force }}
121+
GITHUB_TOKEN: ${{ github.token }}
122+
run: poetry run python update_changelog.py
123+
124+
- name: Create Pull Request.
125+
if: steps.changelog.outputs.changed == 'true'
126+
uses: peter-evans/create-pull-request@v8
127+
with:
128+
token: ${{ secrets.PAT }}
129+
committer: Ilya (Marshal) <ilya@marshal.dev>
130+
author: Ilya (Marshal) <ilya@marshal.dev>
131+
commit-message: ${{ steps.changelog.outputs.commit_message }}
132+
body: This PR was automatically generated from the release notes
133+
base: main
134+
branch: update-changelog-v${{ steps.changelog.outputs.version }}
135+
title: ${{ steps.changelog.outputs.commit_message }}
136+
assignees: MarshalX

update_changelog.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#!/usr/bin/env python3
2+
"""Prepend a new release section to CHANGES.md from a GitHub release. Used in CI/CD."""
3+
4+
import datetime as dt
5+
import os
6+
import sys
7+
import traceback
8+
from pathlib import Path
9+
10+
import httpx
11+
12+
_CHANGES_PATH = Path(__file__).parent.joinpath('CHANGES.md').absolute()
13+
14+
_GITHUB_API_BASE_URL = 'https://api.github.com'
15+
_DEFAULT_REPO = 'MarshalX/atproto'
16+
17+
_TOP_HEADING = '# Change Log'
18+
19+
_DROP_HEADING = "## What's Changed"
20+
_FOOTER_MARKERS = ('**Full Changelog**', '## New Contributors')
21+
22+
23+
def _emit_github_error(message: str, title: str = 'update_changelog.py failed') -> None:
24+
"""Surface a failure as a GitHub Actions error annotation (no-op outside CI)."""
25+
if 'CI' not in os.environ:
26+
return
27+
28+
# Escape per the GitHub workflow-command spec so multi-line tracebacks render.
29+
escaped = message.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A')
30+
print(f'::error title={title}::{escaped}') # noqa: T201
31+
32+
33+
def _set_output(name: str, value: str) -> None:
34+
"""Expose a value to later workflow steps via $GITHUB_OUTPUT."""
35+
github_output = os.environ.get('GITHUB_OUTPUT')
36+
if not github_output:
37+
return
38+
39+
with open(github_output, 'a', encoding='UTF-8') as f:
40+
f.write(f'{name}={value}\n')
41+
42+
43+
def _print(*args) -> None:
44+
if 'CI' in os.environ:
45+
print(*args, file=sys.stderr) # noqa: T201
46+
return
47+
48+
print(*args) # noqa: T201
49+
50+
51+
def _require_env(name: str) -> str:
52+
value = os.environ.get(name)
53+
if not value:
54+
raise RuntimeError(f'Missing required environment variable: {name}')
55+
56+
return value
57+
58+
59+
def _normalize_version(tag: str) -> str:
60+
return tag[1:] if tag.startswith('v') else tag
61+
62+
63+
def _format_date(published_at: str) -> str:
64+
parsed = dt.datetime.fromisoformat(published_at.replace('Z', '+00:00'))
65+
return parsed.strftime('%d.%m.%Y')
66+
67+
68+
def _fetch_release(tag: str) -> dict:
69+
"""Fetch a release from the GitHub API. Empty ``tag`` resolves to the latest release.
70+
71+
Used by the manual (workflow_dispatch) path, where there is no release event payload.
72+
"""
73+
repo = os.environ.get('GITHUB_REPOSITORY') or _DEFAULT_REPO
74+
if tag:
75+
url = f'{_GITHUB_API_BASE_URL}/repos/{repo}/releases/tags/{tag}'
76+
else:
77+
url = f'{_GITHUB_API_BASE_URL}/repos/{repo}/releases/latest'
78+
79+
headers = {'Accept': 'application/vnd.github+json'}
80+
token = os.environ.get('GITHUB_TOKEN')
81+
if token:
82+
headers['Authorization'] = f'Bearer {token}'
83+
84+
_print(f'- Fetching release from {url} ...')
85+
response = httpx.get(url, headers=headers, follow_redirects=True)
86+
response.raise_for_status()
87+
return response.json()
88+
89+
90+
def _clean_body(body: str) -> str:
91+
"""Strip GitHub's auto-notes scaffolding (``## What's Changed`` header and footer)."""
92+
lines = body.replace('\r\n', '\n').split('\n')
93+
94+
# Cut the footer and everything after it
95+
for idx, line in enumerate(lines):
96+
if any(line.strip().startswith(marker) for marker in _FOOTER_MARKERS):
97+
lines = lines[:idx]
98+
break
99+
100+
# Drop the leading heading
101+
lines = [line for line in lines if line.strip() != _DROP_HEADING]
102+
103+
return '\n'.join(lines).strip()
104+
105+
106+
def _build_section(version: str, date: str, body: str) -> str:
107+
return f'## Version {version}\n\n**{date}**\n\n{body}\n'
108+
109+
110+
def _insert_section(changes: str, section: str) -> str:
111+
"""Insert the new section right after the top-level heading, before the latest version."""
112+
heading = f'{_TOP_HEADING}\n'
113+
if not changes.startswith(heading):
114+
raise RuntimeError(f'CHANGES.md does not start with the expected {_TOP_HEADING!r} heading')
115+
116+
rest = changes[len(heading) :].lstrip('\n')
117+
return f'{heading}\n{section}\n{rest}'
118+
119+
120+
def _replace_section(changes: str, version: str, section: str) -> str:
121+
"""Replace an existing ``## Version X.Y.Z`` block in place (used by --force / past releases)."""
122+
marker = f'## Version {version}\n'
123+
start = changes.find(marker)
124+
if start == -1:
125+
raise RuntimeError(f'Version {version} not found in CHANGES.md')
126+
127+
# The next version heading marks the end of this block; -1 means it is the last block.
128+
next_heading = changes.find('\n## Version ', start + len(marker))
129+
end = len(changes) if next_heading == -1 else next_heading + 1
130+
131+
suffix = changes[end:]
132+
# Separate from the following section with a blank line; nothing extra at EOF.
133+
joiner = '\n' if suffix else ''
134+
return f'{changes[:start]}{section}{joiner}{suffix}'
135+
136+
137+
def main() -> None:
138+
"""Prepend a release section to CHANGES.md from the release payload. Used in CI/CD."""
139+
# The release event provides the payload directly via env; manual dispatch only knows the
140+
# tag (or nothing -> latest), so we fetch the release from the API. RELEASE_DATE is the
141+
# discriminator: it is always present on a release event, never on a manual dispatch.
142+
if os.environ.get('RELEASE_DATE'):
143+
tag = _require_env('RELEASE_TAG')
144+
published_at = os.environ['RELEASE_DATE']
145+
body = os.environ.get('RELEASE_BODY', '')
146+
else:
147+
release = _fetch_release(os.environ.get('RELEASE_TAG', ''))
148+
tag = release['tag_name']
149+
published_at = release['published_at']
150+
body = release.get('body') or ''
151+
152+
version = _normalize_version(tag)
153+
date = _format_date(published_at)
154+
body = _clean_body(body)
155+
156+
force = os.environ.get('FORCE', '').strip().lower() in ('true', '1', 'yes')
157+
158+
changes = _CHANGES_PATH.read_text(encoding='UTF-8')
159+
section = _build_section(version, date, body)
160+
161+
if f'## Version {version}\n' in changes:
162+
if not force:
163+
_print(f'- CHANGES.md already contains version {version}; nothing to do (set force to overwrite).')
164+
_set_output('changed', 'false')
165+
return
166+
_CHANGES_PATH.write_text(_replace_section(changes, version, section), encoding='UTF-8')
167+
_print(f'- Replaced CHANGES.md section for version {version} (force).')
168+
else:
169+
_CHANGES_PATH.write_text(_insert_section(changes, section), encoding='UTF-8')
170+
_print(f'- Added CHANGES.md section for version {version}.')
171+
172+
commit_message = f'Update changelog for v{version}'
173+
_set_output('changed', 'true')
174+
_set_output('version', version)
175+
_set_output('commit_message', commit_message)
176+
print(commit_message) # noqa: T201
177+
178+
179+
if __name__ == '__main__':
180+
try:
181+
main()
182+
except Exception:
183+
_emit_github_error(traceback.format_exc())
184+
raise

0 commit comments

Comments
 (0)