Last year I wrote a post about how I implemented an overly complex approval workflow with GitHub Actions. While it wasn’t the simplest solution, at the time it was a means to an end as we didn’t have any built-in way to do approval workflows with GitHub Actions. At the end of last year that changed with the introduction of Environments (announcement post). Environments bring in the concept of protection rules, which currently supports two types, required reviewers and a wait timer, which is exactly what we need for an approval workflow.
So with this available to us, let’s look at taking the workflow to publish GitHub Packages and turn it into an approval-based workflow.
Setting up Environments
Navigate to the GitHub repo you want to set this up on and then go to Settings
-> Environments
.
From here we can create new Environments. You can make as many as you need, and you can have different sets of environments for different workflows, they don’t have to be reused or generic. We’ll create two environments, one called build
, which will be the normal compilation step of our workflow and one called release
, which will have the approval on it and used to publish to our package registry (I’m using npm here, but it could be NuGet, or anything else).
On the Configure release screen we’ll add a protection rule of Required reviewer , and I’ve added myself as the person required, but set whoever is the right person for this environment (you can nominate up to 6 people).
Remember to click Save protection rules (I kept forgetting!), and your environments are good to go.
Implementing our workflow
With the Environments setup, we can now return to our GitHub Actions workflow and overhaul it to work with the Environments. We’ll also take this opportunity to have our workflow create a GitHub Release for us as well.
To achieve this, we’ll have four distinct environments, build
to create the package and draft a GitHub Release, release
to publish the GitHub Release, publish-npm
to publish the package to npm and publish-gpr
to publish to GitHub Packages. The release
stage will need to wait until build
has completed, and we’ve approved the release, and the two publish
environments will wait for the release
stage to complete.
Note: publish-npm
and publish-gpr
aren’t created as Environments in GitHub but they are implicit Environments. You could create explicit environments if you wanted protection rules, but I wanted to show how you can use explicit and implicit Environments together.
Let’s scaffold the workflow:
name: Publish a release
on:
push:
tags:
- v* #version is cut
env:
NODE_VERSION: 12
jobs:
It’s going to be triggered on a new version tag being pushed, which I like to do manually.
The build
stage
We'll start by associating the build
job with the Environment:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: react-static-web-apps-auth
environment:
name: build
url: ${{ steps.create_release.outputs.html_url }}
steps:
Note: you can ignore the working-directory
default, I need that due to the structure of my Git repo. It's left in for completeness of the workflow file at the end.
To link the job to the Environment we created in GitHub we add an environment
node and provide it the name of the Environment we created, build
in this case. You can optionally provide an output URL to the run, and since we'll be creating a draft Release, we can use that as the URL, but if you were deploying to somewhere, then you could use the URL of the deployed site.
Now we can add the steps needed:
steps:
- uses: actions/checkout@v2
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: true
prerelease: false
Here we're using actions/create-release
to create a Release on GitHub and setting it to draft
, as it's not yet approved. This step has an id
set, create_release
, which is what we used to get the release URL for the Environment output and will need to upload artifacts shortly.
You can add the appropriate build/test/etc. steps after this one, again this is an example with a JavaScript project and I'm using npm, so change to your platform of choice:
- uses: actions/setup-node@v1
with:
node-version: ${{ env.NODE_VERSION }}
- run: |
npm ci
npm run lint
npm run build
npm pack
With this step we're generating the package that will go to our package registry, but since we're not publishing yet (that's a future jobs responsibility), we need a way to make it available to the future jobs. For that we'll publish it as an artifact of the workflow, using actions/upload-artifact
:
- name: Upload
uses: actions/upload-artifact@v2
with:
name: package
path: "react-static-web-apps-auth/*.tgz"
It'd also be good if the Release we're creating had the package attached to it, if people want to download it rather than use a package registry, and we can do that with actions/upload-release-asset
. The only problem is that we need to find out the full name of the package, including version, but that's dynamic. To tackle this I create an environment variable containing the tag, extracted from GITHUB_REF
using some bash magic:
- run: echo "tag=${GITHUB_REF##*/v}" >> $GITHUB_ENV
- name: Upload package to release
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: "react-static-web-apps-auth/aaronpowell-react-static-web-apps-auth-${{ env.tag }}.tgz"
asset_name: "aaronpowell-react-static-web-apps-auth-${{ env.tag }}.tgz"
asset_content_type: application/zip
Again, we're using the create_release
step output to get the URL needed to upload the assets, another reason why you need to give that step an id
.
The last thing that this job needs to do is let the future ones (in particular release
) know what the id
of the GitHub Release is, so it can publish it from draft. It doesn't look like the step outputs are available across environments (and this is something I also hit with Azure Pipelines), so the solution I have for this is to put it in a text file and upload it as an artifact of the build.
- run: echo ${{ steps.create_release.outputs.id }} >> release.txt
- name: Upload
uses: actions/upload-artifact@v2
with:
name: release_id
path: react-static-web-apps-auth/release.txt
build
is done, time for release
.
The release
stage
Like build
, the release
stage needs to have an environment
node that references the correct Environment name, this is how GitHub will know to apply the protection rules for you. But since this Environment doesn't have any output, we're not going to need to set a url
property.
release:
needs: build
runs-on: ubuntu-latest
environment:
name: release
You'll also notice the needs
property in there as well. This tells us that this job can't run until build
has completed, which makes sense as we're waiting on some outputs from there.
This phase of our workflow will only be responsible the draft status from the GitHub Release, and to do that we'll need to call the GitHub API and tell it which Release to edit, so we'll need to artifact that we published at the end of the last job.
steps:
- name: Download package
uses: actions/download-artifact@v2
with:
name: release_id
- run: echo "release_id=$(cat release.txt)" >> $GITHUB_ENV
- name: Publish release
uses: actions/github-script@v3
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
github.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
draft: false
})
We download the artifact with actions/download-artifact
and then export the context of the text file as an environment variable called release_id
. Then, in the actions/github-script
step we’ll use the updateRelease
operation. Since actions/github-script
is running as a JavaScript script, to access environment variables we can use process.env
, and that gives us access to process.env.release_id
as needed.
With this complete, our release is no longer in draft and we can publish the packages to their respective registries.
Publishing to npm and GitHub Packages
I’ll only show the workflow steps for npm here, as GitHub Packages is virtually the same and can be read about in this post.
This part of our workflow is rather straight forward since we’ve already built our package, all that’s left to do is download the artifact from the current run and publish to npm.
publish-npm:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Download package
uses: actions/download-artifact@v2
with:
name: package
- uses: actions/setup-node@v1
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org/
- run: npm publish $(ls *.tgz) --access public
env:
NODE_AUTH_TOKEN: ${{secrets.npm_token}}
As we have the tgz
file, we don’t need to repack, we’ll just pass the filename into npm publish
(obtained from ls *.tgz
), and since it’s a scoped package that everyone can use, we are setting the access to public
.
Running the workflow
With the new workflow ready to run, all it takes is a push with a tag for it to kick off. When the build
phase completes, the reviewer(s) will receive an email and a notice on the Action in the GitHub UI.
Approve it, and the rest of the stages will run through to completion (hopefully…).
Conclusion
Throughout this post we’ve created a new GitHub Action workflow that will build and release a package, but still give us the safety net of requiring a manual approval step before it is ultimately released.
You can find the successful run I demonstrated here on my own project, and the commit diff from a previous project that released to npm automatically.
Have you had a chance to implement anything using the approval process in GitHub Actions? Let me know as I’d love to see what else people are doing with it.