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.
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.
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.
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.
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!
Next post
Increase LXC Disk Size in Proxmox