GitHub Actions Basics

A complete GitHub Actions workflow that checks out code, installs dependencies, and runs tests on every push and PR.

What GitHub Actions is

GitHub Actions is GitHub's built-in CI/CD engine. You define workflows as YAML files committed to your repository at .github/workflows/*.yml, and GitHub runs them automatically whenever the events you configure occur — a push, a pull request, a schedule, or a manual trigger.

A complete example workflow

Here is a full, working workflow that checks out a Node.js project, installs its dependencies, and runs its test suite on every push and pull request:

YAML
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Drop this file in .github/workflows/ci.yml, commit it, and push — GitHub picks it up automatically. No separate CI server to install or maintain.

Breaking it down

on — triggers

The on key defines which events start the workflow:

YAML
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

This runs the workflow on every push to main, and on every pull request targeting main (from any branch, including forks). Other common triggers:

YAML
on:
  schedule:
    - cron: '0 3 * * *'   # every day at 03:00 UTC
  workflow_dispatch: {}    # adds a "Run workflow" button in the GitHub UI

jobs — units of work

A workflow is made of one or more jobs. Each job runs on a fresh virtual machine (a "runner") specified by runs-on, and by default, jobs in the same workflow run in parallel unless you tell one to wait on another with needs:

YAML
jobs:
  test:
    runs-on: ubuntu-latest
    # ...

  lint:
    runs-on: ubuntu-latest
    # ...

  build:
    needs: [test, lint]   # waits for both to succeed first
    runs-on: ubuntu-latest
    # ...

steps — the sequence within a job

Each job runs a list of steps in order, top to bottom. A step either:

  • runs a reusable action with uses: (e.g. actions/checkout@v4 — a packaged, versioned unit someone else wrote), or
  • runs a raw shell command with run: (executed on the runner's shell — bash by default on Linux runners).
YAML
steps:
  - name: Checkout code
    uses: actions/checkout@v4      # clones your repo onto the runner

  - name: Set up Node.js
    uses: actions/setup-node@v4    # installs the requested Node.js version
    with:
      node-version: '20'

  - name: Install dependencies
    run: npm ci                    # a raw shell command

  - name: Run tests
    run: npm test

actions/checkout@v4 deserves special mention: a fresh runner starts with an empty filesystem — without this step as the first one, there is no repository code to build or test at all.

Pinning versions

Always pin actions to a version (@v4, or better, a full commit SHA for supply-chain security) rather than @main — an unpinned action can change behavior under you without warning.

Common mistakes

  • Forgetting actions/checkout as the first step — every later step then runs against an empty directory.
  • Using npm install instead of npm ci in CI — npm ci installs exactly what's in package-lock.json and fails loudly on a mismatch, which is what you want in an automated pipeline; npm install can silently update the lockfile.
  • Not scoping on.push.branches — without it, the workflow fires on pushes to every branch, which is often wasteful and slows down feedback on unrelated work.
  • Assuming jobs run in sequence by default — they run in parallel unless you add needs.