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 cache —
node_modulesis cached across runs keyed onpackage-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-on —
start: npm startruns your app server in the background;wait-onpolls the URL until it's ready before launching tests. - Browser provisioning — Chrome, Firefox, and Edge are pre-installed on
ubuntu-latestrunners.
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/reportsif: 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 UIA 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: 7Two 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-onand racing the tests against the server boot.npm startreturns immediately because it forks the server process. Withoutwait-on, the firstcy.visithits a server that's not listening yet and fails withECONNREFUSED. Always pairstart:withwait-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. Thesecretscontext 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.
- Push your scaffolded Cypress project to a fresh GitHub repo (or pick an existing one).
- Create
.github/workflows/cypress.ymlwith the minimal workflow from the lesson. Trigger onpull_request. Push a branch and open a PR. - Add a deliberate failure — change one assertion to fail. Push. Confirm the PR turns red and the failure shows in the Actions tab.
- Add the artifact upload with
if: failure(). Re-trigger the failure. Download the artifact and inspect the screenshots and video. - Inject a secret — add
CYPRESS_BASE_URLto repo secrets pointing at any public site. Reference it via${{ secrets.CYPRESS_BASE_URL }}in the workflow'senv. ConfirmCypress.env("BASE_URL")reads the value at test time. Confirm the secret never appears in workflow logs. - Add a browser matrix — run Chrome and Firefox in parallel. Confirm two status checks appear on the PR.
- Stretch: add a nightly schedule (
cron: "0 6 * * *") plus aworkflow_dispatchtrigger. 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.