GitHub Actions is what most teams reach for first when they want CI. There's no server to provision, no agent to configure, no plugins to install. You add one YAML file to your repository and GitHub runs your tests on every push and pull request — for free on public repos, with a generous free tier on private ones. This lesson breaks down the anatomy of a workflow so the YAML you write in the next three lessons makes immediate sense.
How GitHub Actions fits into your project
Actions workflows live at a specific path inside your repository: .github/workflows/. Every .yml file in that directory is a separate workflow. You can have as many as you need — one for running tests on PRs, one for nightly regression, one for deploying to staging. They all live in your repository alongside your code and test files, which means they're version-controlled, reviewable in pull requests, and rolled back if something goes wrong.
A minimal workflow, annotated
# .github/workflows/tests.yml
name: Tests # the name shown in the Actions UI
on: # what triggers this workflow
push:
branches: [main]
pull_request:
branches: [main]
jobs: # one or more jobs (run in parallel by default)
run-tests: # the job ID — appears in PR status checks
runs-on: ubuntu-latest # the machine GitHub provisions for this job
timeout-minutes: 30 # kill the job if it runs longer than this
steps: # ordered list of actions within the job
- uses: actions/checkout@v4 # clone the repo into the runner
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci # run a shell command
- name: Run tests
run: npm testSix concepts to lock in:
name: — what appears in the Actions tab and in PR status checks. Make it meaningful: "Tests", "Selenium Smoke", "Playwright Cross-Browser". The job ID (run-tests above) is what you reference in branch protection rules.
on: — the trigger. A workflow does nothing until something fires it. push fires on commits to specified branches. pull_request fires when a PR is opened or updated. schedule fires on a cron expression. workflow_dispatch adds a manual "Run workflow" button to the Actions UI.
jobs: — a workflow contains one or more jobs. By default, jobs run in parallel. Use needs: [job-id] to make one job wait for another.
runs-on: — the type of machine GitHub provisions. ubuntu-latest is by far the most common for QA — Chrome, Firefox, and Edge come pre-installed. windows-latest and macos-latest exist for platform-specific testing but burn more minutes.
steps: — the ordered list of work inside a job. Steps run sequentially. If one step fails (non-zero exit code), subsequent steps are skipped and the job fails — unless you add if: always().
uses: vs run: — steps are either reusable actions from the GitHub Marketplace (uses: actions/checkout@v4) or shell commands (run: npm test). The @v4 pin is a version tag — always pin to a version, never use @latest in CI, so your pipeline doesn't break when an action author makes a breaking change.
Triggers in detail
on:
push:
branches: [main, 'release/**'] # push to main or any release/* branch
paths-ignore: ['**.md', 'docs/**'] # skip docs-only changes
pull_request:
branches: [main]
types: [opened, synchronize, reopened] # default; explicit for clarity
schedule:
- cron: '0 2 * * *' # every night at 2:00 AM UTC
workflow_dispatch: # manual trigger — shows a "Run workflow" button
inputs:
environment:
description: 'Target environment'
type: choice
options: [staging, production]
default: stagingFor QA: pull_request is your primary trigger for running tests on every PR. schedule is your nightly regression trigger. workflow_dispatch is essential for running a specific suite against a specific environment on demand.
Jobs: parallel and sequential
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: mvn -B package -DskipTests
test:
needs: build # wait for build to complete first
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: mvn -B test -Dheadless=trueneeds: build makes the test job wait until build succeeds. Remove needs and the jobs run in parallel — which is usually what you want for independent jobs like lint, unit test, and type check.
Pre-built actions from the Marketplace
You'll use these constantly:
| Action | What it does |
|---|---|
actions/checkout@v4 | Clones your repo into the runner |
actions/setup-node@v4 | Installs Node.js and optionally caches npm/yarn |
actions/setup-java@v4 | Installs a JDK with Maven/Gradle cache support |
actions/upload-artifact@v4 | Saves files from a job for later download |
actions/download-artifact@v4 | Retrieves files saved by a previous job |
actions/cache@v4 | General-purpose caching (pip packages, ~/.m2, etc.) |
cypress-io/github-action@v6 | Official Cypress action with built-in parallelisation |
- – push / pull_request
- – schedule (cron)
- – workflow_dispatch
- – runs-on: ubuntu-latest
- – timeout-minutes
- – needs: [job] for sequence
- – uses: pre-built action
- – run: shell command
- – if: always() for cleanup
- ${{ matrix.browser }} –
- ${{ secrets.API_KEY }} –
- ${{ github.run_id }} –
Expressions and contexts
Inside a workflow, ${{ ... }} is an expression. You'll use these constantly:
${{ matrix.browser }}— the current matrix value (Chapter 2, Lesson 3)${{ secrets.STAGING_URL }}— a repository secret (Chapter 2, Lesson 4)${{ github.run_id }}— a unique ID for the current run, useful in artifact names${{ github.event_name }}— the trigger that fired the workflow (push,pull_request,schedule)
⚠️ Common mistakes
- Using
@latestaction tags.uses: actions/checkout@latestbreaks your pipeline silently when the action releases a breaking change. Always pin:@v4,@v3, never@latest. - Not setting
timeout-minutes. Without a timeout, a hung test or a waiting browser can keep a job running for 6 hours and burn your entire monthly minute budget. Set it to 2× your expected runtime. - Putting everything in one giant job. A single job that builds, tests, lints, and deploys runs everything serially and can't be retried in parts. Split responsibilities into separate jobs — lint and unit test can run in parallel; E2E tests can wait for both to pass with
needs.
🎯 Practice task
Create your first GitHub Actions workflow — 30 minutes.
- Create a public or private GitHub repository (or use an existing one with a test project).
- Create the directory
.github/workflows/at the repository root. - Add a file
tests.ymlusing the minimal annotated example above. Replace thenpm testcommand with whatever runs your test suite (Maven, pytest, npm test, npx playwright test — whatever your project uses). - Commit and push. Go to the repository's Actions tab. Watch the workflow run in real time. It will be slow the first time (installing dependencies). Click into the run and read each step's logs.
- Make a deliberate change that breaks one test. Push it. Confirm the workflow shows a red failure and the failing step is highlighted.
- Fix the test. Push. Confirm the workflow goes green.
You now have the foundation. The next lesson builds on it: a complete PR test workflow with artifact uploads, status checks, and the headless flag that makes browser tests work in CI without a display.