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.

Prerequisites

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:

  • You have a GitHub Actions workflow that builds and deploys your project.
  • You already do automatic versioning and tagging of your main branch.
  • You have branch protection rules set up for your main branch to only allow pushes from PRs.

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.

Updating your workflow

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:

Resolve the true commit SHA

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.

Get PR metadata

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.

Check if PR was squashed

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.

Get commit messages with SHAs

This step obtains the commit messages for the PR along with their SHAs. These form the “Changes” section of your release notes.

Build release notes file

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.

Create GitHub Release with release notes and Docker image tags

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.

Conclusion

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.

Previous Post Next Post