DevOps: Enhanced Release Automation with GitHub's AI-Powered Release Notes
In my previous article, “DevOps: Automating Release Tags”, I shared how we automated version tagging and release creation using GitHub Actions. While that solution worked well, the release notes generation was basic, essentially just copying the PR title and description. Today, I’ll show you how we evolved this approach by leveraging GitHub’s powerful automatic release notes generation API.
The Evolution: From Basic to Intelligent Release Notes
Our original workflow created releases with simple summaries, but we wanted something more comprehensive and professional. We envisioned release notes that would include:
- Comprehensive Release Overview: A clear summary of what’s included in the release
- Merged Pull Requests: A complete list of all PRs that contributed to the release
- Contributor Recognition: Acknowledgement of all team members who contributed to this release
- Complete Changelog: A full, detailed changelog showing every change with proper attribution and links
GitHub provides a powerful API that generates release notes automatically based on pull requests, labels, and commit history. This seemed like the perfect solution, but how do we integrate it effectively into our existing automation workflow?
Leveraging the GitHub API for Release Notes Generation
Understanding the GitHub Release Notes API
The GitHub API provides a dedicated endpoint for generating release notes: /repos/{owner}/{repo}/releases/generate-notes
. This endpoint is intelligent. It analyses all changes between two tags, extracts contributors, and formats everything into a polished markdown changelog.
Key API Parameters:
tag_name
: The tag for which you’re generating release notes (required)previous_tag_name
: The tag to compare against (optional, but essential for meaningful changelogs)target_commitish
: The target branch or commit (defaults to the repository’s default branch)
Integration Strategy
Rather than making shell calls with curl
, I discovered that using GitHub’s JavaScript API client within actions/github-script@v7
provided a cleaner, more reliable approach. This eliminated the need for complex JSON manipulation with jq
and provided better error handling.
Here’s how the integration works:
Step 1: Fetch Latest Tag
First, we need to identify the latest tag to compare against our new tagged release:
1
2
3
4
5
6
7
8
- name: Get latest tag
id: get_tag
run: |
git fetch --tags
latest_tag=$(git tag --sort=-v:refname | head -n 1)
echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT
This step is crucial, it gives the API the context it needs to generate a meaningful changelog comparing the latest tagged version with our new tagged release.
Step 2: Tag our New Release
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- name: Bump version and create tag
id: bump_tag
run: |
latest_tag=${{ steps.get_tag.outputs.latest_tag }}
is_major=${{ steps.major_trigger.outputs.major }}
if [[ -z "$latest_tag" ]]; then
new_tag="v1.0.0"
else
IFS='.' read -r major minor patch <<< "${latest_tag#v}"
if [[ "$is_major" == "true" ]]; then
new_tag="v$((major+1)).0.0"
else
new_tag="v$major.$minor.$((patch+1))"
fi
fi
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git tag "$new_tag"
git push origin "$new_tag"
echo "new_tag=$new_tag" >> $GITHUB_OUTPUT
This step creates the new tag for our release based on whether it’s a major or minor release.
Step 3: Call the GitHub API with JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
- name: Generate release notes
id: release_notes
uses: actions/github-script@v7
with:
script: |
const tag = '${{ steps.bump_tag.outputs.new_tag }}';
const latestTag = '${{ steps.get_tag.outputs.latest_tag }}';
// Use GitHub's automatic release notes generation API
const response = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
previous_tag_name: latestTag || undefined, // Use previous tag if available
target_commitish: 'main'
});
// Determine release type based on version number analysis
const major = tag.split('.')[0].replace('v', '');
const minor = tag.split('.')[1];
const patch = tag.split('.')[2];
let releaseType;
if (minor === '0' && patch === '0') {
releaseType = "🚀 Major Release";
} else if (patch === '0') {
releaseType = "✨ Minor Release";
} else {
releaseType = "🐛 Patch Release";
}
// Combine release type with auto-generated notes
const releaseNotes = `${releaseType} ${tag}\n\n${response.data.body}`;
// Write to file for use in release creation
const fs = require('fs');
fs.writeFileSync('release-notes.txt', releaseNotes);
// Also output for debugging
core.setOutput('release_notes', releaseNotes);
core.setOutput('release_name', response.data.name);
- name: Create GitHub Release
if: steps.is_major.outputs.major == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.bump_tag.outputs.new_tag }}" \
--title "Release ${{ steps.bump_tag.outputs.new_tag }}" \
--notes-file release-notes.txt
This step uses the GitHub API to generate the release notes and writes the final notes to a file for the release creation step.
Key Benefits of This Approach:
- Automatic Context Awareness: GitHub’s API analyses commit history, PR titles, and labels to categorise changes intelligently
- Built-in Contributor Detection: The API automatically identifies and credits all contributors
- No Manual Parsing: Unlike shell-based approaches, we don’t need complex regex or JSON parsing
Handling Edge Cases
The implementation accounts for several edge cases:
1. First Release (No Previous Tag)
1
previous_tag_name: latestTag || undefined;
When latestTag
is undefined (first release), the API generates notes for all commits in the repository, which is appropriate for initial releases.
2. No Changes Between Tags The API gracefully handles scenarios where tags are identical or when no PRs exist between versions.
3. Missing PR Metadata If PRs lack proper labels or descriptions, the API uses commit messages and titles as fallbacks.
The Complete Enhanced Workflow
Here’s the complete GitHub Actions workflow that combines automated tagging with intelligent release notes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
name: Enhanced Tag and Release with AI-Powered Notes
on:
push:
branches:
- main
jobs:
tag-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Get latest PR merged
id: pr
uses: actions/github-script@v7
with:
script: |
const prs = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed',
sort: 'updated',
direction: 'desc',
per_page: 1
});
const pr = prs.data.find(pr => pr.merged_at && pr.merge_commit_sha === context.sha);
if (!pr) throw new Error('No merged PR found for this commit.');
core.setOutput('pr_number', pr.number);
core.setOutput('pr_title', pr.title);
core.setOutput('pr_body', pr.body);
- name: Check for major release trigger
id: major_trigger
uses: actions/github-script@v7
with:
script: |
const prNumber = Number(process.env.PR_NUMBER || '${{ steps.pr.outputs.pr_number }}');
let isMajor = false;
if (prNumber) {
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const labels = pr.data.labels.map(l => l.name.toLowerCase());
if (labels.includes('major-release')) isMajor = true;
if (pr.data.title.includes('[major]') || (pr.data.body && pr.data.body.includes('[major]'))) isMajor = true;
} else {
// Fallback: check commit message
const commit = await github.rest.repos.getCommit({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.sha
});
if (commit.data.commit.message.includes('[major]')) isMajor = true;
}
core.setOutput('major', isMajor ? 'true' : 'false');
- name: Get latest tag
id: get_tag
run: |
git fetch --tags
latest_tag=$(git tag --sort=-v:refname | head -n 1)
echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT
- name: Bump version and create tag
id: bump_tag
run: |
latest_tag=${{ steps.get_tag.outputs.latest_tag }}
is_major=${{ steps.major_trigger.outputs.major }}
if [[ -z "$latest_tag" ]]; then
new_tag="v1.0.0"
else
IFS='.' read -r major minor patch <<< "${latest_tag#v}"
if [[ "$is_major" == "true" ]]; then
new_tag="v$((major+1)).0.0"
else
new_tag="v$major.$minor.$((patch+1))"
fi
fi
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git tag "$new_tag"
git push origin "$new_tag"
echo "new_tag=$new_tag" >> $GITHUB_OUTPUT
- name: Check if major version
id: is_major
run: |
tag=${{ steps.bump_tag.outputs.new_tag }}
major=$(echo $tag | cut -d'.' -f1 | tr -d 'v')
minor=$(echo $tag | cut -d'.' -f2)
patch=$(echo $tag | cut -d'.' -f3)
if [[ "$minor" == "0" && "$patch" == "0" ]]; then
echo "major=true" >> $GITHUB_OUTPUT
else
echo "major=false" >> $GITHUB_OUTPUT
fi
- name: Generate release notes
id: release_notes
uses: actions/github-script@v7
with:
script: |
const tag = '${{ steps.bump_tag.outputs.new_tag }}';
const latestTag = '${{ steps.get_tag.outputs.latest_tag }}';
// Use GitHub's automatic release notes generation API
const response = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
previous_tag_name: latestTag || undefined, // Use previous tag if available
target_commitish: 'main'
});
// Determine release type based on version number analysis
const major = tag.split('.')[0].replace('v', '');
const minor = tag.split('.')[1];
const patch = tag.split('.')[2];
let releaseType;
if (minor === '0' && patch === '0') {
releaseType = "🚀 Major Release";
} else if (patch === '0') {
releaseType = "✨ Minor Release";
} else {
releaseType = "🐛 Patch Release";
}
// Combine release type with auto-generated notes
const releaseNotes = `${releaseType} ${tag}\n\n${response.data.body}`;
// Write to file for use in release creation
const fs = require('fs');
fs.writeFileSync('release-notes.txt', releaseNotes);
// Also output for debugging
core.setOutput('release_notes', releaseNotes);
core.setOutput('release_name', response.data.name);
- name: Create GitHub Release
if: steps.is_major.outputs.major == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.bump_tag.outputs.new_tag }}" \
--title "Release ${{ steps.bump_tag.outputs.new_tag }}" \
--notes-file release-notes.txt
Key Improvements Over the Original Workflow
1. Intelligent Release Notes
The GitHub API automatically:
- Summarise changes since last release
- Lists all contributors
- Groups changes logically
- Filters out noise (like dependency updates)
2. Better Historical Context
By comparing against the previous tag, the release notes include:
- All changes since the last release
- Complete PR history with links
- Proper attribution for each contribution
3. Enhanced Major Releases
Major version releases get special treatment:
- Prominent release highlights section
- Assists with highlighting warnings about breaking changes
- Enhanced formatting for visibility
4. Robust Error Handling
The workflow handles edge cases:
- First release (no previous tag)
- Missing PR metadata
Conclusion
By combining GitHub’s automatic release notes API with our existing automated tagging workflow, we’ve created a powerful, hands-off release management system.
The result is a system that:
- Runs completely automatically
- Generates comprehensive, well-formatted release notes
- Recognises all contributors
- Scales effortlessly as the team grows
- Requires zero manual intervention
Click here to view a working example of the complete workflow on GitHub.
If you’re still manually creating release notes, I highly recommend implementing this approach.
Have you implemented automated release notes in your projects? What challenges did you face? Share your experiences in the comments below!