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