Wouldn't it be nice if you could automatically generate GitHub releases with detailed release notes? Writing release notes can be a pain, but if you take care in structuring your branches into logical work packages, writing informative commit messages, and adding summaries to your pull requests, you can automate your GitHub releases based on that content. In this article, I will show you how to set up a GitHub Actions workflow that does just that.
This article assumes that you have a setup similar to a previous article of mine about Bulding and deploying locally using GitHub Actions and webhooks.
The major prerequisites are:
If you followed the previous article, the workflow code you see here will be a direct drop-in into your existing workflow. If you have a different setup, you might need to adjust the workflow to fit your needs.
Perhaps the most critical prerequisite is that you follow semantic versioning and write good commit messages and PR summaries. This is what the workflow will use to generate the release notes and also ensure you get major, minor, and patch versions correctly. This is really important for helping your users understand the impact of changes in your releases.
The partial workflow below is drop-in code for the workflow in the previous article. It will generate release notes based on the PR title, body, and commit messages. It will also generate links to the Docker images that were built in the workflow.
Place the following code (with adaptations to your project) in your existing workflow file, right after the step where you push a new Git tag:
- name: Resolve true commit SHA
id: sha_resolver
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
ACTUAL_SHA=$(git log --merges --pretty=format:%H -1 | xargs -I{} git show {} --no-patch --format=%P | cut -d' ' -f2)
echo "TRUE_SHA=${ACTUAL_SHA:-${{ github.sha }}}" >> $GITHUB_OUTPUT
else
echo "TRUE_SHA=${{ github.sha }}" >> $GITHUB_OUTPUT
fi
- name: Get PR metadata
uses: 8BitJonny/[email protected]
id: pr_meta
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
sha: ${{ steps.sha_resolver.outputs.TRUE_SHA }}
- name: Check if PR was squashed
id: check_squash
run: |
# Fetch PR commits using GitHub API and count them using Python
PR_COMMITS=$(curl -s \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
"https://api.github.com/repos/${{ github.repository }}/pulls/${{ steps.pr_meta.outputs.number }}/commits" | \
python3 -c "import sys, json; print(len(json.load(sys.stdin)))")
echo "Commit count: $PR_COMMITS"
# If there's only one commit, assume it's a squash merge
if [[ "$PR_COMMITS" -eq 1 ]]; then
echo "SQUASH_MERGE=true" >> $GITHUB_ENV
else
echo "SQUASH_MERGE=false" >> $GITHUB_ENV
fi
- name: Get commit messages with SHAs
run: |
if [[ "$SQUASH_MERGE" == "true" ]]; then
# For squash merges, just fetch the single PR commit
response=$(curl -s \
-H "Accept: application/vnd.github+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/pulls/${{ steps.pr_meta.outputs.number }}/commits")
echo "$response" \
| python3 -c "import sys, json; d = json.load(sys.stdin)[0]; print(f\"{d['commit']['message']} ({d['sha'][:7]})\")" \
> commits.txt
else
# For regular merges, exclude lines containing "Merge pull request"
git log --pretty=format:"%s (%h)" ${{ github.event.before }}..${{ github.sha }} \
| grep -v "Merge pull request" \
> commits.txt
fi
- name: Build release notes file
run: |
# Create a file for the release notes.
# Title from the PR:
echo "## ${{ steps.pr_meta.outputs.pr_title }}" > release_body.md
# Conditionally add a "Summary" section if there's a PR body.
if [[ -n "${{ steps.pr_meta.outputs.pr_body }}" ]]; then
echo "" >> release_body.md
echo "### Summary" >> release_body.md
echo "${{ steps.pr_meta.outputs.pr_body }}" >> release_body.md
fi
echo "" >> release_body.md
echo "### Changes" >> release_body.md
awk '{print "• " $0}' commits.txt >> release_body.md
echo "" >> release_body.md
echo "### Docker Tags" >> release_body.md
echo "- Stable: [ghcr.io/<github-org>/<image>:${{ env.VERSION }}](https://ghcr.io/<github-org>/<image>:${{ env.VERSION }})" >> release_body.md
echo "- Major: [ghcr.io/<github-org>/<image>:v${{ env.MAJOR }}](https://ghcr.io/<github-org>/<image>:v${{ env.MAJOR }})" >> release_body.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ env.VERSION }}
name: "Release ${{ env.VERSION }}"
body_path: release_body.md
draft: false
prerelease: false
Let's walk throuh what this means:
With branch protection rules, you can only push to the main branch through a PR. This means that the commit SHA that triggers the workflow is not necessarily the commit SHA we want to use for the release notes. This step resolves the true commit SHA by looking at the commit that created the PR.
This step retrieves the metadata for the PR that was merged, based on the true commit SHA. This is necessary to get the PR title and body, which become part of your release notes.
I couldn't find a direct way to determine if a PR was squashed via the GitHub API, so I had to count the number of commits in the PR. If there's only one commit, we assume it's a squash merge. While not perfect, in practice this tends to work well.
This step obtains the commit messages for the PR along with their SHAs. These form the “Changes” section of your release notes.
A Markdown file (release_body.md) is created and populated with the PR title, optional summary, and commit messages. We also add references to the Docker images we built.
Finally, we create a GitHub release using the softprops/action-gh-release action. The release notes come from our newly generated release_body.md file.
With this workflow, you can automatically generate release notes for your GitHub releases based on merged PRs. This approach helps keep your release notes up to date and consistent. It also encourages structuring your work in a way that makes it easier for others to understand the impact of changes.
One critical piece missing from this snippet is a proper testing stage. You should always test your releases before pushing them to users. This may be a good topic for a future article.
Additionally, you may want to improve the release notes generation by adding more metadata from the PRs and commits, such as labels or assignees. This can help you categorize your changes and give credit to contributors.