GitHub Actions in Action

Gleb Bahmutov

VP of Engineering

Cypress.io

In this presentation, I will show how simple the continuos integration can be with GitHub Actions. They are powerful, have generous limits for public repositories and can be easily reused.

our planet is in imminent danger

https://lizkeogh.com/2019/07/02/off-the-charts/

+3 degrees Celsius will be the end.

we have to act today

ME

you

you

you

you

  • home on green electricity
  • ebike everywhere
  • online conferences instead of flying

we have to act today

ME

you

you

you

you

if I can help I will

@bahmutov

gleb.bahmutov@gmail.com

If there is a company that fights global climate catastrophe and needs JavaScript and testing skills - I will do for free.

(take a deep breath)

Contents

  • What are GitHub (GH) Actions

  • Hello world example

  • Fixing code formatting

  • Testing Node code

    • Caching

    • Writing your own action

  • Testing web apps using Cypress

    • Linux / Mac / Windows

    • parallelization

Dr. Gleb Bahmutov, PhD

these slides

🦉 @bahmutov

Continuous integration (CI): every commit should be built and tested automatically in a clean environment.

Coding without CI is like never changing the oil in your car. It is just a matter of time before it blows up.

me (paraphrasing Justin James)

from https://cypress.slides.com/cypress-io/cypress-on-ci

 

Lots of CI providers

  • Travis
  • CircleCI
  • BuildKite
  • Semaphore
  • AppVeyor
  • GitLab
  • Codeship
  • AzureCI
  • Shippable
  • Jenkins*
  • TeamCity*

* self-managed

NOT WORTH IT

Every CI config file

  • git checkout
  • install dependencies and tools
  • build code
  • start app
  • test app
  • report results

Every CI config file

  • git checkout
  • install dependencies and tools
  • build code
  • start app
  • test app
  • report results

caching!!!

Every CI config file

  • git checkout
  • install dependencies and tools
  • build code
  • start app
  • test app
  • report results

caching!!!

when is it ready?

how to stop it?

Every CI config file

  • git checkout
  • install dependencies and tools
  • build code
  • start app
  • test app
  • report results

caching!!!

when is it ready?

how to stop it?

in parallel?

CI setup: dream

Reality 🤯

version: 2
jobs:
  test:
    docker:
      - image: cypress/base:10
    steps:
      - checkout
      # restore folders with npm dependencies and Cypress binary
      - restore_cache:
          keys:
            - cache-{{ checksum "package.json" }}
      # install npm dependencies and Cypress binary
      # if they were cached, this step is super quick
      - run:
          name: Install dependencies
          command: npm ci
      - run: npm run cy:verify
      # save npm dependencies and Cypress binary for future runs
      - save_cache:
          key: cache-{{ checksum "package.json" }}
          paths:
            - ~/.npm
            - ~/.cache
      # start server before starting tests
      - run:
          command: npm start
          background: true
      - run: npm run e2e:record

workflows:
  version: 2
  build:
    jobs:
      - test

Docker image

Caching

Caching

Install

run tests

maybe start app

Typical front-end web app testing using Cypress

defaults: &defaults
  working_directory: ~/app
  docker:
    - image: cypress/browsers:chrome67

version: 2
jobs:
  build:
    <<: *defaults
    steps:
      - checkout
      # find compatible cache from previous build,
      # it should have same dependencies installed from package.json checksum
      - restore_cache:
          keys:
            - cache-{{ .Branch }}-{{ checksum "package.json" }}
      - run:
          name: Install Dependencies
          command: npm ci
      # run verify and then save cache.
      # this ensures that the Cypress verified status is cached too
      - run: npm run cy:verify
      # save new cache folder if needed
      - save_cache:
          key: cache-{{ .Branch }}-{{ checksum "package.json" }}
          paths:
            - ~/.npm
            - ~/.cache
      - run: npm run types
      - run: npm run stop-only
      # all other test jobs will run AFTER this build job finishes
      # to avoid reinstalling dependencies, we persist the source folder "app"
      # and the Cypress binary to workspace, which is the fastest way
      # for Circle jobs to pass files
      - persist_to_workspace:
          root: ~/
          paths:
            - app
            - .cache/Cypress

  4x-electron:
    <<: *defaults
    # tell CircleCI to execute this job on 4 machines simultaneously
    parallelism: 4
    steps:
      - attach_workspace:
          at: ~/
      - run:
          command: npm start
          background: true
      # runs Cypress test in load balancing (parallel) mode
      # and groups them in Cypress Dashboard under name "4x-electron"
      - run: npm run e2e:record -- --parallel --group $CIRCLE_JOB

workflows:
  version: 2
  # this workflow has 4 jobs to show case Cypress --parallel and --group flags
  # "build" installs NPM dependencies so other jobs don't have to
  #   └ "1x-electron" runs all specs just like Cypress pre-3.1.0 runs them
  #   └ "4x-electron" job load balances all specs across 4 CI machines
  #   └ "2x-chrome" load balances all specs across 2 CI machines and uses Chrome browser
  build_and_test:
    jobs:
      - build
      # this group "4x-electron" will load balance all specs
      # across 4 CI machines
      - 4x-electron:
          requires:
            - build

Parallel config is ... more complicated

- install + run jobs

- workspace

- parallel flags

Typical front-end web app testing using Cypress

# all jobs that actually run tests can use the same definition
job_template: &job_template
  image: cypress/base:10
  stage: test
  script:
    # print CI environment variables for reference
    - $(npm bin)/print-env CI
    # start the server in the background
    - npm run start:ci &
    # run Cypress test in load balancing mode
    - npm run e2e:record -- --parallel --group "electrons on GitLab CI"
  artifacts:
    when: always
    paths:
      - cypress/videos/**/*.mp4
      - cypress/screenshots/**/*.png
    expire_in: 1 day

# actual job definitions
# all steps are the same, they come from the template above
electrons-1:
  <<: *job_template
electrons-2:
  <<: *job_template
electrons-3:
  <<: *job_template
electrons-4:
  <<: *job_template
electrons-5:
  <<: *job_template

YAML tips & tricks

Copy / paste / tweak until CI passes 🙁

# first, install Cypress, then run all tests (in parallel)
stages:
  - build
  - test

# to cache both npm modules and Cypress binary we use environment variables
# to point at the folders we can list as paths in "cache" job settings
variables:
  npm_config_cache: "$CI_PROJECT_DIR/.npm"
  CYPRESS_CACHE_FOLDER: "$CI_PROJECT_DIR/cache/Cypress"

# cache using branch name
# https://gitlab.com/help/ci/caching/index.md
cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .npm
    - cache/Cypress
    - node_modules

# this job installs NPM dependencies and Cypress
install:
  image: cypress/base:10
  stage: build

  script:
    - npm ci
    - $(npm bin)/print-env CI
    - npm run cy:verify

# all jobs that actually run tests can use the same definition
.job_template: &job
  image: cypress/base:10
  stage: test
  script:
    # print CI environment variables for reference
    - $(npm bin)/print-env CI
    # start the server in the background
    - npm run start:ci &
    # run Cypress test in load balancing mode, pass id to tie jobs together
    - npm run e2e:record -- --parallel --ci-build-id $CI_PIPELINE_ID --group electrons

# actual job definitions
# all steps are the same, they come from the template above
electrons-1:
  <<: *job
electrons-2:
  <<: *job
electrons-3:
  <<: *job
electrons-4:
  <<: *job
electrons-5:
  <<: *job
pipeline {
  agent {
    // this image provides everything needed to run Cypress
    docker {
      image 'cypress/base:10'
    }
  }

  stages {
    // first stage installs node dependencies and Cypress binary
    stage('build') {
      steps {
        // there a few default environment variables on Jenkins
        // on local Jenkins machine (assuming port 8080) see
        // http://localhost:8080/pipeline-syntax/globals#env
        echo "Running build ${env.BUILD_ID} on ${env.JENKINS_URL}"
        sh 'npm ci'
        sh 'npm run cy:verify'
      }
    }

    stage('start local server') {
      steps {
        // start local server in the background
        // we will shut it down in "post" command block
        sh 'nohup npm start &'
      }
    }

    // this tage runs end-to-end tests, and each agent uses the workspace
    // from the previous stage
    stage('cypress parallel tests') {
      environment {
        // we will be recordint test results and video on Cypress dashboard
        // to record we need to set an environment variable
        // we can load the record key variable from credentials store
        // see https://jenkins.io/doc/book/using/using-credentials/
        CYPRESS_RECORD_KEY = credentials('cypress-example-kitchensink-record-key')
        // because parallel steps share the workspace they might race to delete
        // screenshots and videos folders. Tell Cypress not to delete these folders
        CYPRESS_trashAssetsBeforeRuns = 'false'
      }

      // https://jenkins.io/doc/book/pipeline/syntax/#parallel
      parallel {
        // start several test jobs in parallel, and they all
        // will use Cypress Dashboard to load balance any found spec files
        stage('tester A') {
          steps {
            echo "Running build ${env.BUILD_ID}"
            sh "npm run e2e:record:parallel"
          }
        }

        // second tester runs the same command
        stage('tester B') {
          steps {
            echo "Running build ${env.BUILD_ID}"
            sh "npm run e2e:record:parallel"
          }
        }
      }

    }
  }

  post {
    // shutdown the server running in the background
    always {
      echo 'Stopping local server'
      sh 'pkill -f http-server'
    }
  }
}
language: node_js

node_js:
  # Node 10.3+ includes npm@6 which has good "npm ci" command
  - 10.8

cache:
  # cache both npm modules and Cypress binary
  directories:
    - ~/.npm
    - ~/.cache
  override:
    - npm ci
    - npm run cy:verify

defaults: &defaults
  script:
    #   ## print all Travis environment variables for debugging
    - $(npm bin)/print-env TRAVIS
    - npm start -- --silent &
    - npm run cy:run -- --record --parallel --group $STAGE_NAME
    # after all tests finish running we need
    # to kill all background jobs (like "npm start &")
    - kill $(jobs -p) || true

jobs:
  include:
    # we have multiple jobs to execute using just a single stage
    # but we can pass group name via environment variable to Cypress test runner
    - stage: test
      env:
        - STAGE_NAME=1x-electron
      <<: *defaults
    # run tests in parallel by including several test jobs with same name variable
    - stage: test
      env:
        - STAGE_NAME=4x-electron
      <<: *defaults
    - stage: test
      env:
        - STAGE_NAME=4x-electron
      <<: *defaults
    - stage: test
      env:
        - STAGE_NAME=4x-electron
      <<: *defaults
    - stage: test
      env:
        - STAGE_NAME=4x-electron
      <<: *defaults

Different CIs

First great reusable CI code

GitHub Actions v2

 🎉 GitHub Actions v2  🎉

GitHub Workflows

GitHub Actions (inside workflows)

GitHub Workflows

    GitHub continuous integration

GitHub Actions (inside workflows)

     Define steps using 3rd party Docker / JavaScript / TypeScript

Hello World workflow

repo/
  .github/
    ISSUE_TEMPLATE.md
    PULL_REQUEST_TEMPLATE.md 
    workflows/
      hello.yml
.github/workflows/hello.yml
name: Hello
on: [push]

jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - run: echo Hello World!
.github/workflows/hello.yml
name: Hello
on: [push]

jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - run: echo Hello World!
  • commit pushed
  • pull request opened / closed/ etc
  • chron job
  • other
.github/workflows/hello.yml
name: Hello
on: [push]

jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - run: echo Hello World!
  • hello job
  • multiple jobs supported
  • jobs can depend on other jobs
.github/workflows/hello.yml
name: Hello
on: [push]

jobs:
  hello:
    runs-on: ubuntu-latest
    steps:
      - run: echo Hello World!
  • run on Linux, Windows, Mac
  • run in a given Docker image

Tip 💡: workflows badge

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=master)

Markdown text

.github/workflows/docs.yml
name: Publish docs
on:
  push:
    branches:
    - master
    paths:
    - 'docs/**'

jobs:
  docs:
    runs-on: ubuntu-latest
    steps:
      ...
  • run workflow if some files change

Tip 💡: multiple workflows

GitHub integration 💪

  • code checkout
  • CI steps
  • ...
  • push code updates?!!

GH Actions are linked to the repo via GitHub Actions app installed automatically

automatic GITHUB_TOKEN

Code formatting with Prettier

{
  "scripts": {
    "format": "prettier --write 'src/*.js'"
  },
  "devDependencies": {
    "husky": "3.0.5",
    "lint-staged": "9.2.5",
    "prettier": "1.18.2"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.js": ["prettier --write", "git add"]
  }
}

but people (even I) forget

.github/workflows/ci.yml
name: Prettier
on: [push]
jobs:
  build:
    name: Prettier
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: bahmutov/npm-install@v1
      - 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 }}

3rd party actions, each is

GH owner / repo @ commitish

.github/workflows/ci.yml
name: Prettier
on: [push]
jobs:
  build:
    name: Prettier
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: bahmutov/npm-install@v1
      - 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 }}

3rd party action with passed GH token, can push code!

commit by action from workflow

Commit from GH Workflow

⚠️ there is no workflow triggered for the commit created from GH Action

Automated workflows cannot trigger other automated workflows (to avoid cycles?). Example: you can automatically open a PR but not close it.

https://github.com/bahmutov/auto-pr-gh-action-example

https://github.com/mikeal/publish-to-github-action
#!/bin/sh

# check values
if [ -z "${GITHUB_TOKEN}" ]; then
    echo "error: not found GITHUB_TOKEN"
    exit 1
fi

# initialize git
remote_repo="https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git config user.name "Automated Publisher"
git config user.email "publish-to-github-action@users.noreply.github.com"
git remote add publisher "${remote_repo}"
git show-ref # useful for debugging
git branch --verbose

# install lfs hooks
git lfs install

# publish any new files
git checkout master
git add -A
timestamp=$(date -u)
git commit -m "Automated publish: ${timestamp} ${GITHUB_SHA}" || exit 0
git pull --rebase publisher master
git push publisher master
#!/bin/sh

# check values
if [ -z "${GITHUB_TOKEN}" ]; then
    echo "error: not found GITHUB_TOKEN"
    exit 1
fi

# initialize git
remote_repo="https://${GITHUB_ACTOR}:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git"
git config user.name "Automated Publisher"
git config user.email "publish-to-github-action@users.noreply.github.com"
git remote add publisher "${remote_repo}"
git show-ref # useful for debugging
git branch --verbose

# install lfs hooks
git lfs install

# publish any new files
git checkout master
git add -A
timestamp=$(date -u)
git commit -m "Automated publish: ${timestamp} ${GITHUB_SHA}" || exit 0
git pull --rebase publisher master
git push publisher master
.github/workflows/ci.yml
name: Prettier
on: [push]
jobs:
  build:
    name: Prettier
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: bahmutov/npm-install@v1
      - 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 }}

3rd party GH action

GH Actions isolate complexity

3rd party GitHub Actions are a way to write CI code using JavaScript / TypeScript / anything

You

Tool's author

Common Problem: Install NPM dependencies with caching

- 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 test

NPM or Yarn install yourself

writer: You

ughh, I never remember these

NPM or Yarn install

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)
}
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)
})

Write regular code to do things on CI

like restore and save cache folder

Describe action

name: 'NPM or Yarn install with caching'
description: 'Install npm dependencies with caching'
author: 'Gleb Bahmutov'
runs:
  using: 'node12'
  main: 'dist/index.js'
branding:
  color: 'yellow'
  icon: 'command'

Declare your action in repo file

- uses: actions/checkout@v1
- uses: bahmutov/npm-install@v1
- run: npm test

NPM or Yarn install

writer: Me (1x)

user: You (100x)

.github/workflows/ci.yml
name: Prettier
on: [push]
jobs:
  build:
    name: Prettier
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: bahmutov/npm-install@v1
      - 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 }}
  • restore NPM cache
  • install NPM deps
  • save cache if needed
steps:
  # latest commit from a branch (DO NOT USE)
  - uses: actions/setup-node@master
  # Reference the major version of a release branch
  # (I use this)
  - uses: actions/setup-node@v1
  # # Reference a minor version of a release (if exists)
  - uses: actions/setup-node@v1.2
  # Reference a specific commit (very strict)
  - uses: actions/setup-node@74bc508

Tip 💡: control the version

Each GH Action needs to be self-contained and ready to go. 

If you use NPM deps, build a single bundle using @zeit/ncc

ncc build -o dist index.js

good example: https://github.com/bahmutov/npm-install

If you want to write your action:

The GitHub Actions ToolKit provides a set of packages to make creating actions easier.

Test on specific Node version

steps:
- uses: actions/checkout@v1
- uses: actions/setup-node@v1
  with:
    node-version: '10.x'
- run: npm install
- run: npm test

Test on many Node versions

jobs:
  build:
    runs-on: ubuntu-16.04
    strategy:
      matrix:
        node: [ '10', '8' ]
    name: Node ${{ matrix.node }} sample
    steps:
      - uses: actions/checkout@v1
      - uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node }}
      - run: npm install
      - run: npm test

creates 2 parallel isolated jobs

Test on many Node versions

jobs:
  build:
    runs-on: ubuntu-16.04
    strategy:
      matrix:
        node: [ '10', '8' ]
    name: Node ${{ matrix.node }} sample
    steps:
      - uses: actions/checkout@v1
      - uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node }}
      - run: npm install
      - run: npm test
  • expressions

Let's test web application!

  • install tool X@1.234.567-alpha89
  • add library Y, Z (also weird versions)
  • sacrifice a goat at full moon 🌝

End-to-end web app testing

Cypress GitHub Action

name: End-to-end tests
on: [push]
jobs:
  cypress-run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: cypress-io/github-action@v1

(does npm install and caching, runs the tests correctly, can build app and start server and more)

Cypress GitHub Action

name: Chome tests
on: [push]
jobs:
  cypress-run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: cypress-io/github-action@v1
        with:
          browser: chrome

Use parameters to control behavior

Cypress GitHub Action

name: Record test results
on: [push]
jobs:
  cypress-run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: cypress-io/github-action@v1
        with:
          record: true
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.cy_token }}

Cypress GitHub Action

name: Parallel tests
on: [push]
jobs:
  cypress-run:
    strategy:
      matrix:
        machines: [1, 2, 3]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v1
      - uses: cypress-io/github-action@v1
        with:
          record: true
          parallel: true
        env:
          CYPRESS_RECORD_KEY: ${{ secrets.cy_token }}

Cypress GitHub Action

parallel-runs-across-platforms:
  strategy:
    matrix:
      os: ['ubuntu-latest', 'windows-latest', 'macos-latest']
      machines: [1, 2]
  runs-on: ${{ matrix.os }}
  steps:
    - uses: actions/checkout@v1
    - uses: cypress-io/github-action@v1
      with:
        record: true
        parallel: true
        group: Parallel 2x on ${{ matrix.os }}
      env:
        CYPRESS_RECORD_KEY: ${{ secrets.cy_token }}

recorded parallel runs

GH Actions

GH Workflows

  • Powerful CI
  • Lots of free resources
  • Great integration with GitHub
  • JavaScript on CI 🎉

Thank you 👏

Gleb Bahmutov

VP of Engineering

Cypress.io

@bahmutov

http://tenminuteheroes.com/ 🦸‍♀️🌎🦸‍♂️

GitHub Actions in Action - NERDSummit

By Gleb Bahmutov

GitHub Actions in Action - NERDSummit

In this presentation, I will show how simple the continuos integration can be with GitHub Actions. They are powerful, have generous limits for public repositories and can be easily reused. In particular, I will show a few use cases that can be of interest to any JavaScript or TypeScript developer: - How to run Prettier inside a GH Action and push any updated code back to the repository - How to correctly install Node dependencies with a single YAML line - How to run end-to-end Cypress tests without pulling your hair out. Video at https://www.youtube.com/watch?v=zhPqf5z_crc

  • 305
Loading comments...

More from Gleb Bahmutov