Skip to content

Commit 85395f7

Browse files
feat(ci): implement automated release notes generation workflow
- Add GitHub Actions workflow triggered on release creation or manual dispatch - Implement commit categorization using conventional commit patterns - Generate structured release notes with commit links and author attribution - Support automatic release updates and draft release creation - Include contributors list and comparison URLs - Add comprehensive error handling and artifact generation
1 parent fbb2463 commit 85395f7

File tree

1 file changed

+238
-0
lines changed

1 file changed

+238
-0
lines changed
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
name: Generate Release Notes
2+
3+
on:
4+
release:
5+
types: [created, published]
6+
workflow_dispatch:
7+
inputs:
8+
tag_name:
9+
description: 'Tag name for the release'
10+
required: true
11+
type: string
12+
target_commitish:
13+
description: 'Target branch or commit (default: main)'
14+
required: false
15+
default: 'main'
16+
type: string
17+
18+
jobs:
19+
generate-release-notes:
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: write
23+
pull-requests: read
24+
25+
steps:
26+
- name: Checkout code
27+
uses: actions/checkout@v4
28+
with:
29+
fetch-depth: 0
30+
token: ${{ secrets.GITHUB_TOKEN }}
31+
32+
- name: Setup Node.js
33+
uses: actions/setup-node@v4
34+
with:
35+
node-version: '20'
36+
37+
- name: Get release information
38+
id: release_info
39+
run: |
40+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
41+
TAG_NAME="${{ github.event.inputs.tag_name }}"
42+
TARGET_COMMITISH="${{ github.event.inputs.target_commitish }}"
43+
else
44+
TAG_NAME="${{ github.event.release.tag_name }}"
45+
TARGET_COMMITISH="${{ github.event.release.target_commitish }}"
46+
fi
47+
48+
echo "tag_name=$TAG_NAME" >> $GITHUB_OUTPUT
49+
echo "target_commitish=$TARGET_COMMITISH" >> $GITHUB_OUTPUT
50+
51+
# Get previous tag
52+
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "$TAG_NAME" | head -n 1)
53+
if [ -z "$PREVIOUS_TAG" ]; then
54+
# If no previous tag, use first commit
55+
PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD)
56+
fi
57+
echo "previous_tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
58+
59+
# Get version without 'v' prefix
60+
VERSION=${TAG_NAME#v}
61+
echo "version=$VERSION" >> $GITHUB_OUTPUT
62+
63+
- name: Generate categorized commit list
64+
id: categorize_commits
65+
run: |
66+
# Create arrays for different categories
67+
declare -a features=()
68+
declare -a bugfixes=()
69+
declare -a breaking=()
70+
declare -a documentation=()
71+
declare -a internal=()
72+
declare -a dependencies=()
73+
declare -a other=()
74+
75+
# Get commits since last release
76+
COMMITS=$(git log ${{ steps.release_info.outputs.previous_tag }}..${{ steps.release_info.outputs.tag_name }} --pretty=format:"%h|%s|%an" --no-merges)
77+
78+
# Categorize commits
79+
while IFS='|' read -r hash subject author; do
80+
if [[ -z "$hash" ]]; then continue; fi
81+
82+
# Convert to lowercase for matching
83+
lower_subject=$(echo "$subject" | tr '[:upper:]' '[:lower:]')
84+
85+
if [[ $lower_subject =~ ^feat(\(.*\))?!: ]] || [[ $lower_subject =~ breaking ]]; then
86+
breaking+=("- $subject ([${hash}](https://github.com/${{ github.repository }}/commit/${hash})) by @${author}")
87+
elif [[ $lower_subject =~ ^feat(\(.*\))?: ]] || [[ $lower_subject =~ ^add ]] || [[ $lower_subject =~ ^implement ]]; then
88+
features+=("- $subject ([${hash}](https://github.com/${{ github.repository }}/commit/${hash})) by @${author}")
89+
elif [[ $lower_subject =~ ^fix(\(.*\))?: ]] || [[ $lower_subject =~ ^bug ]] || [[ $lower_subject =~ ^patch ]]; then
90+
bugfixes+=("- $subject ([${hash}](https://github.com/${{ github.repository }}/commit/${hash})) by @${author}")
91+
elif [[ $lower_subject =~ ^docs(\(.*\))?: ]] || [[ $lower_subject =~ documentation ]] || [[ $lower_subject =~ readme ]]; then
92+
documentation+=("- $subject ([${hash}](https://github.com/${{ github.repository }}/commit/${hash})) by @${author}")
93+
elif [[ $lower_subject =~ ^chore(\(.*\))?: ]] || [[ $lower_subject =~ ^ci(\(.*\))?: ]] || [[ $lower_subject =~ ^test(\(.*\))?: ]] || [[ $lower_subject =~ ^refactor(\(.*\))?: ]]; then
94+
internal+=("- $subject ([${hash}](https://github.com/${{ github.repository }}/commit/${hash})) by @${author}")
95+
elif [[ $lower_subject =~ ^deps(\(.*\))?: ]] || [[ $lower_subject =~ dependencies ]] || [[ $lower_subject =~ package ]] || [[ $lower_subject =~ bump ]]; then
96+
dependencies+=("- $subject ([${hash}](https://github.com/${{ github.repository }}/commit/${hash})) by @${author}")
97+
else
98+
other+=("- $subject ([${hash}](https://github.com/${{ github.repository }}/commit/${hash})) by @${author}")
99+
fi
100+
done <<< "$COMMITS"
101+
102+
# Export arrays as multiline strings
103+
printf '%s\n' "${features[@]}" > features.txt
104+
printf '%s\n' "${bugfixes[@]}" > bugfixes.txt
105+
printf '%s\n' "${breaking[@]}" > breaking.txt
106+
printf '%s\n' "${documentation[@]}" > documentation.txt
107+
printf '%s\n' "${internal[@]}" > internal.txt
108+
printf '%s\n' "${dependencies[@]}" > dependencies.txt
109+
printf '%s\n' "${other[@]}" > other.txt
110+
111+
- name: Get contributors
112+
id: contributors
113+
run: |
114+
# Get unique contributors for this release
115+
CONTRIBUTORS=$(git log ${{ steps.release_info.outputs.previous_tag }}..${{ steps.release_info.outputs.tag_name }} --pretty=format:"%an" --no-merges | sort -u | sed 's/^/- @/' | tr '\n' '\n')
116+
echo "contributors<<EOF" >> $GITHUB_OUTPUT
117+
echo "$CONTRIBUTORS" >> $GITHUB_OUTPUT
118+
echo "EOF" >> $GITHUB_OUTPUT
119+
120+
- name: Generate release notes
121+
id: generate_notes
122+
run: |
123+
# Read template
124+
TEMPLATE=$(cat .github/release-notes-template.md)
125+
126+
# Read categorized sections
127+
FEATURES=$(cat features.txt || echo "No new features in this release.")
128+
BUGFIXES=$(cat bugfixes.txt || echo "No bug fixes in this release.")
129+
BREAKING=$(cat breaking.txt || echo "No breaking changes in this release.")
130+
DOCUMENTATION=$(cat documentation.txt || echo "No documentation changes in this release.")
131+
INTERNAL=$(cat internal.txt || echo "No internal changes in this release.")
132+
DEPENDENCIES=$(cat dependencies.txt || echo "No dependency updates in this release.")
133+
OTHER=$(cat other.txt || echo "No other changes in this release.")
134+
135+
# Replace placeholders
136+
NOTES="$TEMPLATE"
137+
NOTES="${NOTES//\{version\}/${{ steps.release_info.outputs.version }}}"
138+
NOTES="${NOTES//\{features\}/$FEATURES}"
139+
NOTES="${NOTES//\{bugfixes\}/$BUGFIXES}"
140+
NOTES="${NOTES//\{breaking\}/$BREAKING}"
141+
NOTES="${NOTES//\{documentation\}/$DOCUMENTATION}"
142+
NOTES="${NOTES//\{internal\}/$INTERNAL}"
143+
NOTES="${NOTES//\{dependencies\}/$DEPENDENCIES}"
144+
NOTES="${NOTES//\{other\}/$OTHER}"
145+
NOTES="${NOTES//\{compare_url\}/https://github.com/${{ github.repository }}/compare/${{ steps.release_info.outputs.previous_tag }}...${{ steps.release_info.outputs.tag_name }}}"
146+
NOTES="${NOTES//\{contributors\}/${{ steps.contributors.outputs.contributors }}}"
147+
NOTES="${NOTES//\{commits\}/[View all commits](https://github.com/${{ github.repository }}/compare/${{ steps.release_info.outputs.previous_tag }}...${{ steps.release_info.outputs.tag_name }})}"
148+
149+
# Add placeholder sections for manual editing
150+
NOTES="${NOTES//\{upgrade_notes\}/<!-- Add any upgrade notes or migration steps here -->}"
151+
NOTES="${NOTES//\{known_issues\}/<!-- Add any known issues or caveats here -->}"
152+
153+
# Save to file
154+
echo "$NOTES" > release-notes.md
155+
156+
# Also set as output for updating release
157+
echo "notes<<EOF" >> $GITHUB_OUTPUT
158+
echo "$NOTES" >> $GITHUB_OUTPUT
159+
echo "EOF" >> $GITHUB_OUTPUT
160+
161+
- name: Update release with generated notes
162+
if: github.event_name == 'release'
163+
uses: actions/github-script@v7
164+
with:
165+
github-token: ${{ secrets.GITHUB_TOKEN }}
166+
script: |
167+
const fs = require('fs');
168+
const releaseNotes = fs.readFileSync('release-notes.md', 'utf8');
169+
170+
// Get the current release
171+
const release = await github.rest.repos.getReleaseByTag({
172+
owner: context.repo.owner,
173+
repo: context.repo.repo,
174+
tag: '${{ steps.release_info.outputs.tag_name }}'
175+
});
176+
177+
// Update the release with generated notes
178+
await github.rest.repos.updateRelease({
179+
owner: context.repo.owner,
180+
repo: context.repo.repo,
181+
release_id: release.data.id,
182+
body: releaseNotes
183+
});
184+
185+
console.log('Release notes updated successfully!');
186+
187+
- name: Create draft release (for manual dispatch)
188+
if: github.event_name == 'workflow_dispatch'
189+
uses: actions/github-script@v7
190+
with:
191+
github-token: ${{ secrets.GITHUB_TOKEN }}
192+
script: |
193+
const fs = require('fs');
194+
const releaseNotes = fs.readFileSync('release-notes.md', 'utf8');
195+
196+
try {
197+
// Try to get existing release
198+
const existingRelease = await github.rest.repos.getReleaseByTag({
199+
owner: context.repo.owner,
200+
repo: context.repo.repo,
201+
tag: '${{ steps.release_info.outputs.tag_name }}'
202+
});
203+
204+
// Update existing release
205+
await github.rest.repos.updateRelease({
206+
owner: context.repo.owner,
207+
repo: context.repo.repo,
208+
release_id: existingRelease.data.id,
209+
body: releaseNotes
210+
});
211+
212+
console.log('Existing release updated with generated notes!');
213+
} catch (error) {
214+
if (error.status === 404) {
215+
// Create new draft release
216+
const release = await github.rest.repos.createRelease({
217+
owner: context.repo.owner,
218+
repo: context.repo.repo,
219+
tag_name: '${{ steps.release_info.outputs.tag_name }}',
220+
target_commitish: '${{ steps.release_info.outputs.target_commitish }}',
221+
name: `Release ${{ steps.release_info.outputs.version }}`,
222+
body: releaseNotes,
223+
draft: true,
224+
prerelease: false
225+
});
226+
227+
console.log(`Draft release created: ${release.data.html_url}`);
228+
} else {
229+
throw error;
230+
}
231+
}
232+
233+
- name: Upload release notes as artifact
234+
uses: actions/upload-artifact@v4
235+
with:
236+
name: release-notes-${{ steps.release_info.outputs.version }}
237+
path: release-notes.md
238+
retention-days: 30

0 commit comments

Comments
 (0)