Pull Request Driven Releases
This recipe keeps an open pull request at all times previewing the changes the Knope will include in the next release. This pull request will let you see the next version, the changes to versioned files, and the changelog. When you merge that pull request, Knope will create a new release with the changes from the pull request.
This is the recipe that Knope uses for its own releases (at the time of writing), so let's walk through the two GitHub Actions workflows and the knope.toml
that make it work.
knope.toml
We're going to walk through this in pieces for easier explanation, but all of these TOML snippets exist in the same file.
[package]
[package]
versioned_files = ["Cargo.toml"]
changelog = "CHANGELOG.md"
This first piece defines the package, Cargo.toml
is both the source of the current version of the package and a place we'd like to place new version numbers. You can add more versioned_files
(for example, if you also released this as a Python package with pyproject.toml
). CHANGELOG.md
is where we want to document changes in the source code—this is in addition to GitHub releases.
You cannot use this recipe right now with multiple packages due to limitations on variables. Instead, you can check out the workflow dispatch workflow.
[[package.assets]]
[[package.assets]]
path = "artifact/knope-x86_64-unknown-linux-musl.tgz"
[[package.assets]]
path = "artifact/knope-x86_64-pc-windows-msvc.tgz"
[[package.assets]]
path = "artifact/knope-x86_64-apple-darwin.tgz"
[[package.assets]]
path = "artifact/knope-aarch64-apple-darwin.tgz"
package.assets
let us define a list of files to upload to GitHub releases. You can also upload them under a different name if you don't want to use the file name by setting name
in the asset definition.
prepare-release
workflow
[[workflows]]
name = "prepare-release"
[[workflows.steps]]
type = "Command"
command = "git switch -c release"
[[workflows.steps]]
type = "PrepareRelease"
[[workflows.steps]]
type = "Command"
command = "git commit -m \"chore: prepare release $version\" && git push --force --set-upstream origin release"
[workflows.steps.variables]
"$version" = "Version"
[[workflows.steps]]
type = "CreatePullRequest"
base = "main"
[workflows.steps.title]
template = "chore: prepare release $version"
variables = { "$version" = "Version" }
[workflows.steps.body]
template = "This PR was created by Knope. Merging it will create a new release\n\n$changelog"
variables = { "$changelog" = "ChangelogEntry" }
The first workflow is called prepare-release
, so it can be executed by running knope prepare-release
(as we'll see later in the GitHub Actions workflow). First, it creates a new branch from the current one called release
, then it runs the PrepareRelease
step which updates our package based on the changes that have been made since the last release. It also stages all of those changes with Git (like git add
).
Next, we commit the changes that PrepareRelease
made—things like:
- Updating the version in
Cargo.toml
- Adding a new section to
CHANGELOG.md
with the latest release notes - Deleting any changesets that have been processed
This commit is pushed to the release
branch, using the --force
flag in this case because we don't care about the history of that branch, only the very next release. The CreatePullRequest
step then creates a pull request from the current branch (release
) to the specified base branch (main
). We can set the title and body of this pull request using templated strings containing variables. In this case, the title contains the new Version
and the body contains the new ChangelogEntry
.
The pull request that this creates looks something like this:
All we have to do now is run this prepare-release
workflow in GitHub Actions whenever we want a new release preview—we'll take a look at that once we finish going through the knope.toml
file.
release
workflow
[[workflows]]
name = "release"
[[workflows.steps]]
type = "Release"
The release
workflow is a single Release
step—this creates a GitHub release for the latest version (if it hasn't already been released) and uploads any assets. In this case, it'll create a release for whatever the prepare-release
workflow made earlier. We'll end up running this workflow whenever the pull request is merged.
document-change
workflow
This isn't super relevant to the recipe, but it's useful to have. Changesets are what let us have really descriptive, rich text in changelogs to describe exactly how the latest changes impact our users. Running knope document-change
executes the CreateChangeFile
workflow to help us make changesets. A future iteration of this recipe may convert pull request comments into changesets 🤞.
[[workflows]]
name = "document-change"
[[workflows.steps]]
type = "CreateChangeFile"
[github]
The last piece is to tell Knope which GitHub repo to use for creating pull requests and releases.
[github]
owner = "knope-dev"
repo = "knope"
prepare_release.yml
There are two GitHub Actions workflows that we're going to use for this recipe—the first one goes in .github/workflows/prepare_release.yml
and it creates a fresh release preview pull request on every push to the main
branch:
on:
push:
branches: [main]
name: Create Release PR
jobs:
prepare-release:
if: "!contains(github.event.head_commit.message, 'chore: prepare release')" # Skip merges from releases
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.PAT }}
- name: Configure Git
run: |
git config --global user.name GitHub Actions
git config user.email github-actions@github.com
- uses: knope-dev/action@v2.0.0
with:
version: 0.11.0
- run: knope prepare-release --verbose
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
continue-on-error: true
This workflow runs by default on every push to main, that includes when the previous release PR merges! There is an if:
clause here in the first job that skips it if the commits matches the commit message that we use in the prepare-release
workflow. If you change that message, you'll need to update this if:
clause as well.
The steps here:
- Check out the entire history of the repo (so that
PrepareRelease
can use tags and conventional commits to determine the next version). This requires a personal access token with permission to read the contents of the repo. - Configure Git so that we can commit changes (within Knope's
prepare-release
workflow) - Install Knope
- Run the
prepare-release
workflow described above. This requires a personal access token with permission to write the pull requests of the repo.
We add the continue-on-error
attribute so that even if this step fails, the workflow will be marked as passing. This is because we want to be able to run this workflow on every push to main
, but we don't want it to fail when there's nothing to release. However, this doesn't differentiate between legitimate errors and "nothing to release". You may want to instead use the allow_empty
option in knope.toml
and split the rest of the steps into a second workflow. Then, you can use some scripting in GitHub Actions to skip the rest of the workflow if there's nothing to release.
In the case of this action, we're using the same personal access token for both steps, but you could use different ones if you wanted to.
release.yml
Now that we're set up to create pull requests previewing the next release on every push to main
, we need to automatically release those changes when the pull request merges. This is the job of the release
workflow, which goes in .github/workflows/release.yml
.
YAML is very sensitive to white space and very easy to mess up copy/pasting—so I recommend copying the whole file at the end, not the individual pieces I'm using to describe functionality.
To start off, we only want to run this workflow when our release preview pull requests merge—there are several pieces of config that handle this. First:
on:
pull_request:
types: [closed]
branches: [main]
Will cause GitHub Actions to only trigger anything at all when a pull request which targets main
closes. Then, in our first job, we can use this an if
to narrow that down further to only our release preview pull requests, and only when they merge (not close for other reasons):
if: github.head_ref == 'release' && github.event.pull_request.merged == true
For Knope's own workflows, this first job is build-artifacts
, which builds the package assets that will be uploaded when releasing. Skipping on past that job (since it probably will be different for you), we come to the release
job:
release:
needs: [build-artifacts]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with:
name: ${{ env.archive_name }}
- uses: knope-dev/action@v2.0.0
with:
version: 0.11.0
- run: knope release
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
- run: gh workflow run "Deploy Book to GitHub Pages"
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
The release
job follows these steps:
- Check out the repo at the commit that the pull request merged
- Download the artifacts that were built in the
build-artifacts
job - Install Knope
- Run the
release
workflow described above. This requires a personal access token with permission to write the contents of the repo. - Kick off another workflow which updates these docs that you're reading 👋. That requires a personal access token with permission to write the actions of the repo.
Finally, Knope's workflow publishes to crates.io—meaning the whole workflow looks like this:
name: Release
on:
pull_request:
types: [closed]
branches: [main]
jobs:
build-artifacts:
if: github.head_ref == 'release' && github.event.pull_request.merged == true
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-musl
os: ubuntu-latest
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
runs-on: ${{ matrix.os }}
name: ${{ matrix.target }}
steps:
- uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4
- uses: Swatinem/rust-cache@v2
- name: Install host target
run: rustup target add ${{ matrix.target }}
- name: Install musl-tools
if: ${{ matrix.target == 'x86_64-unknown-linux-musl' }}
run: sudo apt-get install -y musl-tools
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Set Archive Name (Non-Windows)
id: archive
run: echo "archive_name=knope-${{ matrix.target }}" >> $GITHUB_ENV
- name: Set Archive Name (Windows)
if: ${{ matrix.os == 'windows-latest' }}
run: echo "archive_name=knope-${{ matrix.target }}" | Out-File -FilePath $Env:GITHUB_ENV -Encoding utf8 -Append
- name: Create Archive Folder
run: mkdir ${{ env.archive_name }}
- name: Copy Unix Artifact
if: ${{ matrix.os != 'windows-latest' }}
run: cp target/${{ matrix.target }}/release/knope ${{ env.archive_name }}
- name: Copy Windows Artifact
if: ${{ matrix.os == 'windows-latest' }}
run: cp target/${{ matrix.target }}/release/knope.exe ${{ env.archive_name }}
- name: Create Tar Archive
run: tar -czf ${{ env.archive_name }}.tgz ${{ env.archive_name }}
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
path: ${{ env.archive_name }}.tgz
if-no-files-found: error
release:
needs: [build-artifacts]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
- uses: knope-dev/action@v2.0.0
with:
version: 0.11.0
- run: knope release
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
- run: gh workflow run "Deploy Book to GitHub Pages"
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
publish-crate:
needs: [release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac # v4
- uses: Swatinem/rust-cache@v2
- uses: katyo/publish-crates@v2
with:
registry-token: ${{ secrets.CARGO_TOKEN }}
Conclusion
Just to summarize, what we get with all of this is a process that:
- Automatically creates a pull request in GitHub every time a new commit is pushed to
main
. That pull request contains a preview of the next release. - Automatically releases the package every time a release preview's pull request is merged.