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.

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/[email protected]

- name: Cache node modules
uses: actions/[email protected]
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/[email protected]
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 master

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/[email protected] # Reference a specific commit
- uses: actions/[email protected] # Reference the major version of a release
- uses: actions/[email protected] # Reference a minor version of a release
- uses: actions/[email protected] # Reference a branch

I recommend using either latest published branch like:

1
2
- uses: actions/[email protected]
- uses: actions/[email protected]

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/[email protected]
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/[email protected]
- uses: bahmutov/[email protected]
- run: npm t

Name bahmutov/[email protected] 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.

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/[email protected]
# Install NPM dependencies, cache them correctly
# and run all Cypress tests
- name: Cypress run
uses: cypress-io/[email protected]

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/[email protected]

# because of "record" and "parallel" parameters
# these containers will load balance all found tests among themselves
- name: Cypress run
uses: cypress-io/[email protected]
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.