GitHub Actions Basics — Workflows, Jobs, Steps

9 min read

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 test

Six 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: staging

For 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=true

needs: 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:

ActionWhat it does
actions/checkout@v4Clones your repo into the runner
actions/setup-node@v4Installs Node.js and optionally caches npm/yarn
actions/setup-java@v4Installs a JDK with Maven/Gradle cache support
actions/upload-artifact@v4Saves files from a job for later download
actions/download-artifact@v4Retrieves files saved by a previous job
actions/cache@v4General-purpose caching (pip packages, ~/.m2, etc.)
cypress-io/github-action@v6Official Cypress action with built-in parallelisation
GitHub Actions Workflow
  • – 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 @latest action tags. uses: actions/checkout@latest breaks 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.

  1. Create a public or private GitHub repository (or use an existing one with a test project).
  2. Create the directory .github/workflows/ at the repository root.
  3. Add a file tests.yml using the minimal annotated example above. Replace the npm test command with whatever runs your test suite (Maven, pytest, npm test, npx playwright test — whatever your project uses).
  4. 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.
  5. Make a deliberate change that breaks one test. Push it. Confirm the workflow shows a red failure and the failing step is highlighted.
  6. 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.

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