Trying GitHub Actions

Run Prettier inside a GitHub Action to fix code formatting, quick NPM install with caching inside actions, running end-to-end Cypress tests using custom action.

Recently GitHub Actions went into general availability with very generous usage limits for public repositories, and I have started playing with them. Here are a couple of experiments.

Note: I have covered the topics below in my recent talk GitHub Actions in Actions, slides.

Fixing code formatting

It is easy to forget to format code before pushing it to GitHub. I usually use husky pre-commit hooks with lint-staged but that requires configuration. It would be so much simpler if continuous integration server could run the formatting task and if there were any changed files, would commit and push them to the source repository, fixing any problems. Turns out, this is pretty (pun intended) simple as the example repo bahmutov/gh-action-with-prettier shows. Here is the .github/workflows/ci.yml file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
name: Prettier
on: [push]

jobs:
build:
name: Prettier
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1

- name: Cache node modules
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
- run: npm run format
- run: git status
# commit any changed files
# https://github.com/mikeal/publish-to-github-action
- uses: mikeal/publish-to-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

The above steps check out the remote source code, install NPM modules (with caching using actions/cache helper), then run Prettier via npm run format and finally use action mikeal/publish-to-github-action I have found at GitHub Marketplace. This action is super simple - it is a code I usually have written myself to commit local changes and push to remote, see its entrypoint.sh.

The integration of code repository (in this case GitHub) with CI (GitHub Actions) is very convenient from the security point of view. In this case, a secret GITHUB_TOKEN is automatically injected by the CI - allowing us to easily interact with the remote repository, no extra steps necessary.

Code was formatted and pushed to the repo

Update: I now prefer using stefanzweifel/git-auto-commit-action to commit and push any changed files.

1
2
3
4
5
6
7
# if there are any updated PNG images, commit and push them
- name: Commit any changed images ๐Ÿ’พ
uses: stefanzweifel/git-auto-commit-action@v4
with:
commit_message: Updated screenshots
branch: main
file_pattern: '*.png'

Action versioning

Actions are fetched directly from GitHub repositories, not from NPM. Thus you can specify what action to use using a branch name, tag or commit.

1
2
3
4
5
steps:
- uses: actions/setup-node@74bc508 # Reference a specific commit
- uses: actions/setup-node@v1 # Reference the major version of a release
- uses: actions/[email protected] # Reference a minor version of a release
- uses: actions/setup-node@master # Reference a branch

I recommend using either latest published branch like:

1
2
- uses: actions/setup-node@v1
- uses: actions/[email protected]

Security

Using branch tags is dangerous though, read this post since you can execute unknown code when the tag changes. Thus if you want to sleep slightly better at night, please use the full commit sha of the actions you have reviewed.

NPM or Yarn install

GitHub has published Actions Toolkit for writing actions using JavaScript or TypeScript. This is excellent - always bet on JavaScript! The only problem - some actions for a typical Node project require quite a bit of copy / paste code. For example, every Node project needs to cache ~/.npm folder, thus it needs the following boilerplate action.

1
2
3
4
5
6
7
8
- name: Cache node modules
uses: actions/cache@v1
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci

Ughh, wouldn't it be nice to have nice reusable "Install NPM modules and cache them, please" action? Unfortunately, action/cache itself is an action - and cannot be used from JavaScript โ˜น๏ธ. There is a little bit of discussion here on the issue I have opened, but worry not - Open Source to the rescue. I have cloned actions/cache into cypress-io/github-actions-cache and have refactored the code in branch reusable-functions to allow using restoreCache and saveCache functions from other JavaScript code. Easy-peasy.

Let's write npm-install action - here is the main logic of the action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
restoreCachedNpm()
.then(npmCacheHit => {
console.log('npm cache hit', npmCacheHit)

return install().then(() => {
if (npmCacheHit) {
return
}

return saveCachedNpm()
})
})
.catch(error => {
console.log(error)
core.setFailed(error.message)
})

If the previously cached ~/.npm or ~/.cache/yarn depending on the presence of yarn.lock folder was successfully restored, then we perform immutable install using npm ci or yarn --frozen-lockfile command and are done. If the cache hit was missed, then we need to save the NPM modues folder in action's cache.

Restoring and saving NPM cache folder functions use the forked cache module and rely on platform and lock file hash to know when a new cache is necessary.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const hasha = require('hasha')
// we are using dependency
// "cache": "github:cypress-io/github-actions-cache#8bec6cc"
const { restoreCache, saveCache } = require('cache/lib/index')

const useYarn = fs.existsSync('yarn.lock')
const lockFilename = useYarn ? 'yarn.lock' : 'package-lock.json'
const lockHash = hasha.fromFileSync(lockFilename)
const platformAndArch = `${process.platform}-${process.arch}`

// this is simplified code for clarity
// see action file index.js for full details
const NPM_CACHE = {
inputPath: '~/.npm', // or '~/.cache/yarn'
primaryKey: `npm-${platformAndArch}-${lockHash}`,
restoreKeys: `npm-${platformAndArch}-`
}

const restoreCachedNpm = () => {
console.log('trying to restore cached NPM modules')
return restoreCache(
NPM_CACHE.inputPath,
NPM_CACHE.primaryKey,
NPM_CACHE.restoreKeys
)
}

const saveCachedNpm = () => {
console.log('saving NPM modules')
return saveCache(NPM_CACHE.inputPath, NPM_CACHE.primaryKey)
}

Building action

Because the action needs to be ready to go, you need to bundle the action using zeti/ncc for example. Thus the action's package.json file includes the build script.

package.json
1
2
3
4
5
6
7
8
{
"scripts": {
"build": "ncc build -o dist index.js"
},
"devDependencies": {
"@zeit/ncc": "0.20.5"
}
}

The generated dist folder is checked in - because GitHub actions are fetched straight from GitHub source, no from NPM registry. For publishing I use another GitHub action cycjimmy/semantic-release-action that tags and pushes new releases on GitHub and also update v<major version> branch, like v1 to always point at the latest release.

Finally, I have described action's main properties in action.yml and published it on GitHub Marketplace.

The lonely NPM install action

You can see this action in ... action at bahmutov/npm-install-action-example/actions. The CI file is simple

.github/workflows/main.yml
1
2
3
4
5
6
7
8
9
10
name: main
on: [push]
jobs:
build-and-test:
runs-on: ubuntu-latest
name: Build and test
steps:
- uses: actions/checkout@v1
- uses: bahmutov/npm-install@v1
- run: npm t

Name bahmutov/npm-install@v1 refers to branch v1 of the GitHub repository bahmutov/npm-install where the latest semantic release is pushed. On the first build, the cache is empty, and npm ci has to fetch NPM modules from the registry. Then the folder ~/.npm is cached.

First install

On the second build, the cache is hit, and npm ci is faster - because it uses only modules from the restored ~/.npm folder, and then skips saving unchanged cache folder.

Second install

Nice, feel free to use this action from your projects, and open new issue if you find a problem. You can also build your own actions using the exported NPM function.

Testing on every OS

You can run the same job on every OS using matrix strategy

1
2
3
4
5
6
7
8
jobs:
build-and-test:
strategy:
# do not stop the matrix if one of the jobs fails
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-20.04]
runs-on: ${{ matrix.os }}

End-to-end testing

Finally, I have written cypress-io/github-action to make running Cypress tests on GitHub super simple. Here is how to run tests on a single Linux machine

1
2
3
4
5
6
7
8
9
10
11
12
name: End-to-end tests
on: [push]
jobs:
cypress-run:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v1
# Install NPM dependencies, cache them correctly
# and run all Cypress tests
- name: Cypress run
uses: cypress-io/github-action@v1

Here is more complicated case: running tests in parallel in load balancing mode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
name: Parallel Cypress Tests

on: [push]

jobs:
test:
name: Cypress run
runs-on: ubuntu-latest
strategy:
matrix:
# run 3 copies of the current job in parallel
containers: [1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v1

# because of "record" and "parallel" parameters
# these containers will load balance all found tests among themselves
- name: Cypress run
uses: cypress-io/github-action@v1
with:
record: true
parallel: true
group: 'Actions example'
env:
# pass the Dashboard record key as an environment variable
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

Super simple and even works across Windows, Mac and Linux machines on CI, see Cypress GitHub Action examples.

NPM publishing

If you are a fan of semantic versioning like I am, you are probably using semantic-release to publish NPM packages automatically from CI. This type of release becomes even simpler with GitHub actions thanks to cycjimmy/semantic-release-action.

First, go to https://www.npmjs.com/settings//tokens and get a new "Read and Write" token. Save it to clipboard - it will never be displayed again!

Second, go to the project's Settings / Secrets and add a new secret with name NPM_TOKEN and paste the NPM auth token from the clipboard.

Add the following step to your workflow

1
2
3
4
5
6
7
8
9
10
11
12
# after test step
- name: Semantic Release
uses: cycjimmy/semantic-release-action@v2
id: semantic
with:
branch: main
extra_plugins: |
@semantic-release/git
@semantic-release/changelog
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

The GITHUB_TOKEN secret is automatically created and injected by the GH Action App in your repository, you don't need to create it. Each time there is a Git commit on the main branch since the last release, the above action will publish new NPM version and will create a GitHub release. See example in action in repo bahmutov/cy-spok, where you can see github-actions user publishing releases.

Semantic release from GH Action

Badges

You can add GH Action badge to your README file. I prefer using the syntax that includes workflow name and explicit branch:

1
2
3
https://github.com/<OWNER>/<REPOSITORY>/workflows/<WORKFLOW_NAME>/badge.svg?branch=<BRANCH>
# example
![cy-spok status](https://github.com/bahmutov/cy-spok/workflows/main/badge.svg?branch=main)

cy-spok status

If you want to use badge as a link and go to the Actions tab of the repo, I like separating the urls into their own lines.

1
2
3
[![ci status][ci image]][ci url]
[ci image]: https://github.com/bahmutov/cy-spok/workflows/main/badge.svg?branch=main
[ci url]: https://github.com/bahmutov/cy-spok/actions

One thing I like doing is creating separate workflows for the same project and putting multiple badges in the same README. If there are example projects, we could put their badges there too. This creates a single CI status "dashboard" in the Markdown file, something I have recommended a long time ago

Status badges Markdown table

Status badges

Set commit status

You can set commit status using GitHub REST API, if you know GH repository and commit SHA and have a token with permissions. A REST call using curl would look like this:

1
2
3
4
5
6
7
8
9
curl --request POST \
--url https://api.github.com/repos/${{ github.repository }}/statuses/${{ github.sha }} \
--header 'authorization: Bearer ${{ secrets.GITHUB_TOKEN }}' \
--header 'content-type: application/json' \
--data '{
"state": "success",
"description": "REST commit status",
"context": "a test"
}'

When running on GitHub Actions, the repository and sha are already set via default environment variables, and the user can pass the GITHUB_TOKEN created by the GH App itself.

1
2
3
4
5
# reads GITHUB_REPOSITORY and GITHUB_SHA from environment
- name: Set code coverage commit status ๐Ÿ“ซ
run: npx set-gh-status
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

See implementation in bahmutov/check-code-coverage.

Commit status check

Deploying GitHub Pages

I like using peaceiris/actions-gh-pages to deploy the repository or a single folder to GitHub pages.

1
2
3
4
- name: Deploy ๐Ÿš€
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}

Running a job on specific branch

You can run a specific job only when doing a push to a branch using if syntax

1
2
3
4
5
deploy:
runs-on: ubuntu-20.04
if: github.ref == 'refs/heads/master'
steps:
...

Running a command step only on specific branch

The same approach works for individual steps:

1
2
3
4
5
- name: Publish Image
# Develop branch only
if: github.ref == 'refs/heads/develop'
run: |
... publish commands ...

Running periodic jobs

You can use cron syntax to run the workflow periodically. For example, to run the tests every night use the following workflow trigger syntax:

1
2
3
on:
schedule:
- cron: '0 3 * * *'

Job dependencies

A job can require other job(s) to successfully finish first

1
2
3
4
5
6
7
jobs:
build:
...
test:
...
release:
needs: [build, test]

If you have a single required job:

1
2
release:
needs: test

Example CI workflow from bahmutov/cypress-wait-if-happens

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
name: ci
on: push
jobs:
test:
runs-on: ubuntu-20.04
steps:
- name: Checkout ๐Ÿ›Ž
uses: actions/checkout@v3
# install and run the tests

test-cypress-v9:
runs-on: ubuntu-20.04
steps:
- name: Checkout ๐Ÿ›Ž
uses: actions/checkout@v3
# install and run the tests

release:
needs: [test, test-cypress-v9]
runs-on: ubuntu-20.04
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout ๐Ÿ›Ž
uses: actions/checkout@v3
...

See Workflow syntax.

Skip a job

You can skip a step or a job using a false expression. For example, to skip a job

1
2
3
4
5
6
test-js:
runs-on: ubuntu-20.04
# temporarily skip testing JavaScript
if: ${{ false }}
steps:
...

Manual dispatch

You can start a workflow from the web GUI, and even provide the input parameters. It is really convenient for kicking off complex workflows by a casual user. See the GitHub announcement.

See examples in the blog posts How to Keep Cypress Tests in Another Repo While Using GitHub Actions, How To Tag And Run End-to-End Tests,Faster test execution with cypress-grep.

1
2
3
4
5
6
7
8
9
10
11
12
on:
workflow_dispatch:
inputs:
grep:
description: Part of the test title
required: false
grepTags:
description: Test tags
required: false
burn:
description: Number of times to repeat the tests
required: false

Reusable workflows

A really powerful new feature from GH - reusable workflows. See the blog post The Simplest CI Setup For Running Cypress Tests for details, but here is how to use a public workflow from bahmutov/cypress-workflows repo:

1
2
3
4
5
6
7
8
9
10
11
12
name: ci
on: [push]
jobs:
test:
# https://github.com/bahmutov/cypress-workflows
uses: bahmutov/cypress-workflows/.github/workflows/parallel.yml@v1
with:
n: 3
group: parallel tests
tag: parallel
secrets:
recordKey: ${{ secrets.CYPRESS_RECORD_KEY }}

Print the event

You can print the environment variables starting with GITHUB and the entire github object available to the action

1
2
3
4
5
6
- name: Print GitHub variables ๐Ÿ–จ
run: npx @bahmutov/print-env GITHUB
- name: Dump GitHub event
env:
GITHUB_CONTEXT: ${{ toJson(github.event) }}
run: echo "$GITHUB_CONTEXT"

Or even simpler:

1
2
- name: Dump GitHub event
run: echo ${{ toJson(github.event) }}

Use environment variables as action inputs

The simplest way to use the environment variables as actions inputs is by exporting them as outputs of another step

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- name: Export CI build id ๐Ÿ“ค
run: echo ::set-output name=ci_build_id::${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}
id: export-ci-build-id
- name: Cypress run ๐Ÿงช
# https://github.com/cypress-io/github-action
uses: cypress-io/github-action@v2
with:
spec: 'cypress/integration/email-hint.js'
env: 'hints=${{ github.event.inputs.hints }}'
record: true
group: 'hint'
tag: 'hint'
# the custom build ID
ci-build-id: ${{ steps.export-ci-build-id.outputs.ci_build_id }}

See GitHub answer.

Even simpler is to use the expression directly:

1
2
3
4
5
6
7
8
9
- name: Cypress run ๐Ÿงช
# https://github.com/cypress-io/github-action
uses: cypress-io/github-action@v5
with:
record: true
group: 'hint'
tag: 'hint'
# the custom build ID
ci-build-id: my-run-${{ github.run_id }}-attempt-${{ github.run_attempt }}

set-output is deprecated

The following command set-output has been deprecated.

You can even output using normal console.log statements, for example see trigger-circleci-pipeline

1
console.log(`::set-output name=CircleCIWorkflowUrl::${url}`)

You can use the output from other job steps

1
2
3
4
5
6
- name: Trigger the deployment
run: npx trigger-circleci-pipeline
id: trigger

- name: Print the workflow URL
run: echo Workflow URL ${{ steps.trigger.outputs.CircleCIWorkflowUrl }}

Job summary

In addition to step outputs, you can output nice Markdown text as job summary following the syntax. This example comes from trigger-circleci-pipeline

1
2
3
4
5
6
7
const url = getWebAppUrl(w)
if (process.env.GITHUB_STEP_SUMMARY) {
const summary = `CircleCI workflow ${w.name} URL: ${url}\n`
writeFileSync(process.env.GITHUB_STEP_SUMMARY, summary, {
flag: 'a+',
})
}

Workflow URL in Job summary

Run workflow on specific branch

To run the workflow on the main branch only

1
2
3
4
5
name: Scrape
on:
push:
branches:
- main

To run the workflow on all branches, but the backup branch

1
2
3
4
5
name: Scrape
on:
push:
branches:
- '!backup'

Cast string to a number

All input parameters in the manual dispatch workflow seem to be strings.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
on:
workflow_dispatch:
inputs:
specs:
description: Comma-separates spec filenames without spaces
required: true
recordingTag:
description: Cypress Dashboard recording tag
required: false
default: Trigger specs
machines:
description: Number of machines to use to run these specs
required: false
type: number
default: 1

The print event inputs step shows all strings

1
2
3
4
5
6
7
8
jobs:
print:
runs-on: ubuntu-20.04
steps:
- name: Print inputs ๐Ÿ–จ๏ธ
env:
INPUTS: ${{ toJson(github.event.inputs) }}
run: echo "$INPUTS"
1
2
3
4
5
{
"machines": "3",
"recordingTag": "three",
"specs": "cypress/e2e/app-spec.js,cypress/e2e/persistence-spec.js,cypress/e2e/routing-spec.js"
}

In the blog post Trigger Selected Cypress Specs Using GitHub Actions I pass the input from the workflow to a reusable workflow that needs a number. The only way to cast the string value "machines" into a number for "n" was via JSON:

1
2
3
4
5
specs:
# https://github.com/bahmutov/cypress-workflows
uses: bahmutov/cypress-workflows/.github/workflows/parallel.yml@v1
with:
n: ${{ fromJson(github.event.inputs.machines) }}

More examples