Running Cypress in GitHub Actions

9 min read

A Cypress suite that runs only on developer laptops is a half-finished project. The whole point of automation is that every push gets the same checks; the engineer never has to remember to run the tests; flaky regressions get caught before merge. GitHub Actions is the most popular CI for Cypress projects — free for public repos, generous quotas for private ones, and Cypress maintains a first-party cypress-io/github-action that handles every install/cache/start/run step in a few lines of YAML. This lesson walks through the canonical workflow, secret-injected env vars, browser matrices, and artifact upload.

A minimal workflow

.github/workflows/cypress.yml is the only file you need:

name: Cypress Tests
 
on: [push, pull_request]
 
jobs:
  cypress-run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: cypress-io/github-action@v6
        with:
          build: npm run build
          start: npm start
          wait-on: "http://localhost:3000"

That's a working setup. On every push and every pull request, GitHub Actions checks out the code, installs dependencies, builds the app, starts the server, waits for http://localhost:3000 to respond, runs Cypress, and reports the result back to the pull request.

The official action handles everything that's tedious to write by hand:

  • Dependency cachenode_modules is cached across runs keyed on package-lock.json. Subsequent runs skip the npm install entirely.
  • Cypress binary cache — the ~200 MB Cypress binary is cached separately so re-installs are seconds, not minutes.
  • App startup with wait-onstart: npm start runs your app server in the background; wait-on polls the URL until it's ready before launching tests.
  • Browser provisioning — Chrome, Firefox, and Edge are pre-installed on ubuntu-latest runners.

You almost never need to hand-roll the steps. Reach for the action first; only fall back to bare run: steps for genuinely unusual cases.

Saving artifacts on failure

Screenshots and videos only matter when something fails. Conditional artifact upload keeps your storage budget sane:

- uses: actions/upload-artifact@v4
  if: failure()
  with:
    name: cypress-artifacts
    path: |
      cypress/screenshots
      cypress/videos
      cypress/reports

if: failure() runs this step only when a previous step failed. The artifact appears on the GitHub Actions run page; engineers click it, download a zip, and inspect the screenshots and videos that match the failing tests.

If you've wired up Mochawesome (chapter 7), include cypress/reports/html/ in the path so the HTML report is part of the same artifact.

Triggers — when the workflow runs

The on: block is where you tune CI cost vs coverage:

# Every push and PR — most expensive
on: [push, pull_request]
 
# Only PRs into main — cheap, signals the moment that matters
on:
  pull_request:
    branches: [main]
 
# Nightly smoke run — catches regressions from external dependencies
on:
  schedule:
    - cron: "0 6 * * *"   # 06:00 UTC every day
 
# All three combined
on:
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:        # also allow manual runs from the UI

A common production setup: PR runs for fast feedback, nightly runs for the full suite (longer, more flaky-tolerant), workflow_dispatch for one-off "rerun against staging" needs.

Environment variables and secrets

GitHub Actions injects env vars into the runner. Cypress picks up anything prefixed CYPRESS_* automatically:

- uses: cypress-io/github-action@v6
  env:
    CYPRESS_BASE_URL:        ${{ secrets.STAGING_URL }}
    CYPRESS_ADMIN_EMAIL:     "qa-bot@example.com"
    CYPRESS_ADMIN_PASSWORD:  ${{ secrets.STAGING_ADMIN_PASSWORD }}
    CYPRESS_OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }}

In your test, Cypress.env("BASE_URL") returns the staging URL. The secret never appears in workflow logs (GitHub masks them automatically) and never lands in your repo. This is the chapter-5 environment-variable pattern at the CI layer — same Cypress.env(...) API, different source.

Set the secret values in Repo → Settings → Secrets and variables → Actions. Never paste them into the YAML directly.

Matrix strategy for multiple browsers

Cypress supports Chrome, Firefox, Edge, and Electron. Run the same suite across all of them with a matrix:

jobs:
  cypress-run:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        browser: [chrome, firefox, edge]
 
    steps:
      - uses: actions/checkout@v4
      - uses: cypress-io/github-action@v6
        with:
          browser: ${{ matrix.browser }}
          build: npm run build
          start: npm start
          wait-on: "http://localhost:3000"

Three jobs run in parallel — one per browser — and each gets its own status check on the PR. fail-fast: false means a Firefox failure doesn't abort the Chrome and Edge runs; you see the full picture.

For a smaller suite you might run only Chrome on every PR and the full matrix nightly — same trade-off as the schedule decision above.

A complete production workflow

Combining everything into one realistic file:

name: Cypress
 
on:
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:
 
jobs:
  cypress-run:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        browser: [chrome, firefox]
    steps:
      - uses: actions/checkout@v4
 
      - uses: cypress-io/github-action@v6
        with:
          browser: ${{ matrix.browser }}
          build: npm run build
          start: npm start
          wait-on: "http://localhost:3000"
          wait-on-timeout: 120
          config-file: cypress.config.ts
        env:
          CYPRESS_BASE_URL:       ${{ secrets.STAGING_URL }}
          CYPRESS_ADMIN_PASSWORD: ${{ secrets.STAGING_ADMIN_PASSWORD }}
 
      - name: Generate Mochawesome HTML
        if: always()
        run: npm run cy:report
 
      - name: Upload artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: cypress-${{ matrix.browser }}-artifacts
          path: |
            cypress/screenshots
            cypress/videos
            cypress/reports/html
          retention-days: 7

Two browsers, secret-injected staging URL, Mochawesome report on every run (passing or failing), artifacts uploaded only when something fails. The if: always() on the report step ensures stakeholders get an HTML even when the run is red.

The full pipeline visualised

⚠️ Common mistakes

  • Forgetting wait-on and racing the tests against the server boot. npm start returns immediately because it forks the server process. Without wait-on, the first cy.visit hits a server that's not listening yet and fails with ECONNREFUSED. Always pair start: with wait-on: on the URL the app listens on.
  • Pasting secrets into env: blocks instead of using ${{ secrets.NAME }}. Anything written literally into the YAML is committed to the repo and visible to anyone who can read the workflow file. The secrets context is the only safe path. Rotate immediately if a real secret has ever been committed.
  • Running every test on every push without a fast subset. A 12-minute Cypress run on every commit is a 12-minute round-trip for every "fix typo in comment" push. Have a smoke subset (1-3 minutes) on every push and the full suite on PRs only — chapter 9 returns to this with the tiered-testing strategy.

🎯 Practice task

Wire your project into GitHub Actions end to end. 30-40 minutes.

  1. Push your scaffolded Cypress project to a fresh GitHub repo (or pick an existing one).
  2. Create .github/workflows/cypress.yml with the minimal workflow from the lesson. Trigger on pull_request. Push a branch and open a PR.
  3. Add a deliberate failure — change one assertion to fail. Push. Confirm the PR turns red and the failure shows in the Actions tab.
  4. Add the artifact upload with if: failure(). Re-trigger the failure. Download the artifact and inspect the screenshots and video.
  5. Inject a secret — add CYPRESS_BASE_URL to repo secrets pointing at any public site. Reference it via ${{ secrets.CYPRESS_BASE_URL }} in the workflow's env. Confirm Cypress.env("BASE_URL") reads the value at test time. Confirm the secret never appears in workflow logs.
  6. Add a browser matrix — run Chrome and Firefox in parallel. Confirm two status checks appear on the PR.
  7. Stretch: add a nightly schedule (cron: "0 6 * * *") plus a workflow_dispatch trigger. Manually run the workflow from the Actions tab. Confirm the run uses the same secrets and produces the same artifacts.

The next lesson covers the same setup for the two other CI platforms most enterprise teams use — Jenkins and GitLab CI.

// tip to track lessons you complete and pick up where you left off across devices.