Automatic Release and Build Workflow using GitHub Actions

Janne Kemppainen |

Recently, I’ve been toying with a small personal side project where I wanted to implement automated release management with a press of a button, including version numbering and uploading build artifacts. In this blog post I’ll outline how this approach could be used in other projects, too.

What we’re building?

My desired workflow for creating releases had the following requirements:

  • One manual trigger to start the process with no additional inputs required.
  • Automatic semantic version numbering.
  • Automatic release commits that change the application version in the project files.
  • Automatic release notes generation.
  • Build and upload binaries to the created release.

The application in question is a small CLI tool written in Rust. I wanted to distribute it with downloads from GitHub releases. Since Rust projects require compilation, I needed a way to build and upload separate binaries for Linux, Windows and macOS.

The build step turned out to be the easiest part of the process since there was a ready made action that worked pretty much out of the box. Publishing the release required a bit more attention, but in the end it was rather simple with the use of the correct actions and some custom scripting.

Publish a release

Given a set of merged pull requests after the last release, we want to be able create a new release that has the correct version increment and references the pull requests in the release notes. Most of the heavy lifting in this pipeline is done by the release-drafter/release-drafter action. With a bit of additional scripting we can build a system that also commits the new version number in version control, and finally makes the release public.

Due to the way that the build action that I’m using works, I needed to split the workflow into two separate pipelines. This first pipeline handles everything that is needed to publish the release on GitHub.

name: Publish release

on:
  workflow_dispatch:

jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v3
      - name: Configure git
        run: |
          git config user.name "GitHub Actions"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"          
      - uses: release-drafter/release-drafter@v5
        id: release-drafter
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Update version
        run: |
          NEW_VERSION=$(echo "${{ steps.release-drafter.outputs.tag_name }}" | sed 's/v//')
          sed -i 's/\(^version = \).*/\1"'${NEW_VERSION}'"/' Cargo.toml
          git add Cargo.toml
          git commit -m "chore: release ${{ steps.release-drafter.outputs.tag_name }}" && git push || echo "Version already up to date"          
      - name: Publish release
        uses: actions/github-script@v6
        with:
          github-token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
          script: |
            const { owner, repo } = context.repo;
            await github.rest.repos.updateRelease({
              owner,
              repo,
              release_id: "${{ steps.release-drafter.outputs.id }}",
              draft: false,
            });            

The pipeline is named “Publish release” and the workflow_dispatch trigger means that it can be triggered manually from the repo’s GitHub Actions view. Alternatively, you could also trigger the pipeline programmatically from any computer but the details for that are out of the scope of this article. If you’re interested in a programmatic trigger, you can find more information from the GitHub documentation for the workflow dispatch event.

The release job runs on the latest Ubuntu runner and gets write permissions for the repository. These permissions are needed for creating a release or pushing commits to the repository.

The steps part starts with cloning the repository and configuring git ready for committing. The git configuration sets the username and email to the GitHub Actions bot. The email may seem a bit random, but it will make the commits appear as made by the bot.

GitHub Actions release

The next step calls the release-drafter action to create a new draft release. This action handles all the heavy lifting for calculating the next semantic version and building the release notes for you. It requires a configuration file to work, you can start with something as simple like this:

name-template: "v$RESOLVED_VERSION"
tag-template: "v$RESOLVED_VERSION"
version-resolver:
  major:
    labels:
      - "breaking"
  minor:
    labels:
      - "type: feature"
  default: patch
template:
  ## What's changed

  $CHANGES

This configuration sets how the release name, tag and body are generated. The next version number is determined based on labels on the closed pull requests. For example, any PR with the type: feature label will cause the next version increment to be a minor one, e.g. 0.1.2 to 0.2.0. The default increment with this configuration is patch. Check the release-drafter repository for more available configurations.

The next step creates a separate release commit. In a Rust project, the application version is stored in a file called Cargo.toml. A Cargo configuration file could look something like this:

[package]
name = "my-app"
version = "0.1.3"
edition = "2021"

[dependencies]
anyhow = "1.0.75"
clap = { version = "4.4.7", features = ["derive"] }

As you can see, we need to change the version number in this file so that it matches the version number of the release. In the tag configuration for release-drafter we prepended the tag with a v, so the release tags are in the format v0.1.3. In Cargo.toml the v needs to be omitted, therefore we have a line that sets a variable for the new version.

NEW_VERSION=$(echo "${{ steps.release-drafter.outputs.tag_name }}" | sed 's/v//')

Since we have defined an id: release-drafter for the release-drafter step, we can access its outputs. We insert the tag_name output and then use sed to replace any occurrence of the character v with an empty string.

Now that we have a clean version number, we can use sed again to replace the version number in Cargo.toml:

sed -i 's/\(^version = \).*/\1"'${NEW_VERSION}'"/' Cargo.toml

The -i flag enables in place editing of the file. The regex pattern looks for a line that starts with version = and stores that part in a capture group. Then the line is replaced with the contents of the capture group and the value of the NEW_VERSION variable, surrounded by quotes.

Note
If your project uses some other language or project structure, you’ll need to adjust this line so that the version number is updated correctly. You’ll need to figure out the correct regex pattern and use the correct file name for your project.

Then the changed file needs to be committed back to the repository (note that the second line is quite long):

git add Cargo.toml
git commit -m "chore: release ${{ steps.release-drafter.outputs.tag_name }}" && git push || echo "Version already up to date"

The changes are added to git, a commit is created with the new tag name, and the changes are pushed back to the origin. If, for some reason, your repository is already in a state where the version number is up to date, there is an alternative echo command that prevents the whole pipeline from failing.

As the name suggests, release-drafter creates a draft release, so in the last step we need to publish it using the actions/github-script@v6 action and some custom code. This simply calls the github.rest.repos.updateRelease endpoint to set the draft key to false. Release-drafter gives us the id of the release as one of its outputs which makes this publishing step really easy to implement.

Note

Events that are triggered by the default GITHUB_TOKEN will not create a new workflow run. This is by design to prevent you from accidentally creating recursive workflow runs. If you want to trigger a separate workflow, you’ll need to use a Personal Access Token.

Follow the instructions in GitHub Docs for creating a fine-grained personal access token and store it in the repository settings under Secrets and variables, Actions. Give the token read and write access to repository contents. Create a new repository secret named PERSONAL_ACCESS_TOKEN with the value of the PAT that you just created. Now the pipeline can reference the token using secrets.PERSONAL_ACCESS_TOKEN.

If you wish, you can also implement the possible build and upload steps in this same pipeline. As I already mentioned, I needed to split the pipeline into two due to the way that the build action that I’m using is implemented. Let’s discuss that in the next part.

Build and upload artifacts to the release

There are some generic action implementations for uploading assets for a release, but they are a bit scattered and may or may not do what you want. The actions/upload-release-asset is unfortunately archived and unmaintained, but shogo82148/actions-upload-release-asset seems to be a maintained alternative.

For a generic project you could create a pipeline that listens to a release creation and then builds and publishes the assets using the aforementioned action.

For a Rust project there is also a simpler alternative, rust-build/rust-build.action:

name: Build release

on:
  release:
    types: [published]

jobs:
  release:
    name: release ${{ matrix.target }}
    runs-on: ubuntu-latest
    permissions:
      contents: write
    strategy:
      fail-fast: false
      matrix:
        include:
          - target: x86_64-pc-windows-gnu
            archive: zip
          - target: x86_64-unknown-linux-musl
            archive: tar.gz tar.xz tar.zst
          - target: x86_64-apple-darwin
            archive: zip
    steps:
      - uses: actions/checkout@master
      - name: Compile and release
        uses: rust-build/[email protected]
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          RUSTTARGET: ${{ matrix.target }}
          ARCHIVE_TYPES: ${{ matrix.archive }}

This “Build release” pipeline is triggered when a release is published. As I mentioned earlier, steps that use the secrets.GITHUB_TOKEN do not trigger new workflows. Since we used a PAT in the publish release that will trigger the the release.published event and start this workflow.

The pipeline is almost directly copied and pasted from the rust-build.action configuration examples, with only one change. Instead of using the created event, we use the published event. In theory, if the release-drafter action used our PAT as the secret token, it could too trigger this workflow. But since we want to make sure that the new version number is committed and included in the final binary, it is better to wait for the publish step.

The workflow is configured to run as a matrix of all the supported target operating systems. They can even use different archive formats that are best supported on each platform. The action is able to get the the upload URL from the release event, and the archive files will finally end up in the corresponding release, as we wished.

Handle PR tagging

Since figuring out the next version increment is based on the pull request labels it might be a good idea to automate PR labeling too. I had some issues getting the autolabeler from the release-drafter to work so here is an alternative configuration.

name: PR Labeler

on:
  pull_request_target:
    types: [opened, reopened, synchronize]

jobs:
  update_labels:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/labeler@v4
      - uses: TimonVS/pr-labeler-action@v4
        with:
          repo-token: ${{ secrets.GITHUB_TOKEN }}

This pipeline uses both the actions/labeler as well as TimonVS/pr-labeler-action actions as they handle different parts of the labeling logic.

Let’s start with the actions/labeler configuration:

# Match PR files to labels
"type: docs":
  - "**/*.md"

The labeler action works on files that have been edited in the pull request. In this example we’re only interested in Markdown files, in which case we add the type: docs label to the PR. You can add additional rules for labels here if your project requires them.

The pr-labeler-action, on the other hand, works on branch names. Therefore, we can create a rule set to assign labels based on the branch names like this:

# Match PR branches to labels
"type: feature":
  - "feature/*"
  - "feat/*"
"type: fix":
  - "fix/*"
"type: chore":
  - "chore/*"
  - "ci/*"
"type: docs":
  - "docs/*"

When we’re working on a new feature, we’ll need to create a new branch that starts with the correct prefix, for example feat/my-new-feature. When a pull request is opened, the type: feature label is added automatically. You can configure these rules to your liking, but make sure that your release-drafter version-resolver configuration matches the label names.

Note

The pipeline is triggered to run on the pull_request_target event instead of pull_request. This allows write access to the PR also in the case that the PR comes from a forked repository.

The difference to the normal pull_request event is that the pipeline is run from the main branch instead of the PR branch. Don’t be surprised when your PR doesn’t get labeled when implementing this pipeline. Merge the changes to the main branch and then the PR labeling should start working.

Conclusion

In this post we created a set of GitHub Actions pipelines that handle release management with a push of a button. I hope you found this useful!

Subscribe to my newsletter

What’s new with PäksTech? Subscribe to receive occasional emails where I will sum up stuff that has happened at the blog and what may be coming next.

powered by TinyLetter | Privacy Policy