GitHub Actions: First CI Workflow

Git · Lesson 20 of 28 7 min read Updated

Imagine every time anyone pushes code or opens a pull request, a fresh computer downloads the project, installs it and runs all the tests, then puts a green tick or a red cross next to the commit. That is continuous integration (CI), and GitHub Actions gives it to every GitHub repository for free on public projects.

In this lesson you will write a CI workflow for a small Node.js project, understand each line of the YAML, read the results on a pull request, and learn a few next steps such as a version matrix and secrets.

The project

Meera has a small JavaScript project, bill-split, that splits a restaurant bill between friends. It uses Node's built-in test runner, so it has no dependencies. Her package.json defines a test script:

JSON
{
  "name": "bill-split",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "test": "node --test --test-reporter=spec"
  }
}

On her laptop, npm test runs the three tests in split.test.js:

Terminal
npm test
Output
> bill-split@1.0.0 test
> node --test --test-reporter=spec

✔ splits ₹900 between 3 friends (6.334ms)
✔ rounds to paise (0.5776ms)
✔ rejects zero people (2.2973ms)
ℹ tests 3
ℹ suites 0
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1988.7665

The problem: tests only help if someone runs them. Kabir might push a change without running them, and the bug lands in main. CI makes running them automatic.

Key words

  • A workflow is an automated process, defined in a YAML file inside .github/workflows/ in your repository.
  • An event (trigger) starts a workflow: a push, a pull request, a schedule, a manual button press.
  • A workflow has one or more jobs. Each job runs on a fresh virtual machine called a runner.
  • A job is a list of steps. A step either runs shell commands (run) or uses a ready-made action (uses), a reusable building block published on GitHub.

Writing the workflow

Meera creates the file .github/workflows/ci.yml:

YAML
name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Check out the code
        uses: actions/checkout@v7

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: 26

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

Line by line:

  • name: CI is the label shown in the Actions tab and on pull requests.
  • on: lists the events. This workflow runs on pushes to main and on every pull request (when it is opened and whenever new commits are pushed to it).
  • permissions: contents: read limits what the workflow's automatic token may do. Giving only what a job needs is a good security habit.
  • jobs: holds one job with the id test. runs-on: ubuntu-latest asks for a GitHub-hosted Linux runner. Windows (windows-latest) and macOS (macos-latest) runners exist too.
  • actions/checkout@v7 copies the repository's code onto the runner. Without it, the runner's folder is empty. The @v7 pins the action's major version.
  • actions/setup-node@v7 installs the Node.js version given under with:.
  • npm ci installs exactly what package-lock.json lists (commit that file), and npm test runs the tests. If any run command exits with an error, the step fails, the job fails and the workflow shows a red cross.

YAML is sensitive to indentation. Use spaces only (never tabs), and keep items at the same level lined up exactly. A dash (-) starts a list item, which is why every step begins with one.

Pushing and watching it run

Commit the workflow like any other file:

Terminal
git add .github/workflows/ci.yml package-lock.json
git commit -m "Add CI workflow that runs the tests"
git push

Open the Actions tab on GitHub. A new run appears within seconds; click it to see the job, and click the job to see each step's log, exactly as it would print in a terminal. A green tick means every step passed.

CI on pull requests

The real value appears on pull requests. Suppose Kabir opens a PR that changes the rounding by mistake. The workflow runs on his branch, and the PR page shows a failed check. Clicking Details opens the log, which contains the same error he would see locally:

Output
✖ failing tests:

test at split.test.js:9:1
✖ rounds to paise (3.9841ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  33.3 !== 33.33

Kabir fixes the code and pushes again; the check reruns and turns green. If the repository owner adds a branch protection rule that requires the test check to pass, a PR with a red cross cannot be merged at all, so broken code never reaches main.

Testing several versions with a matrix

A matrix runs the same job several times with different values. This tests both the previous and the current Node.js LTS releases in parallel:

YAML
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [24, 26]
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - run: npm test

${{ ... }} is an expression: GitHub replaces it with a value before the step runs. Here it becomes 24 in one job and 26 in the other.

Secrets and other triggers

Workflows that deploy a website or call an API need passwords or tokens. Never write them in the YAML file. Store them in Settings > Secrets and variables > Actions, then read them in a step with ${{ secrets.DEPLOY_TOKEN }}. GitHub hides secret values in logs, and workflows triggered from forks do not receive your secrets.

Other useful events: workflow_dispatch: adds a Run workflow button for manual runs, schedule: with a cron expression runs a workflow on a timetable, and release: runs when you publish a release.

Common mistakes

  • Saving the file in the wrong place. It must be in .github/workflows/ and end in .yml or .yaml, or GitHub ignores it.
  • Tabs or uneven indentation in YAML. GitHub reports "Invalid workflow file". Use two spaces per level.
  • Forgetting actions/checkout. Later steps fail with "file not found", because the runner starts empty.
  • Committing secrets into the workflow. Use repository secrets instead, and change any secret that was ever committed.

Key points

  • GitHub Actions runs workflows defined in YAML files in .github/workflows/.
  • on: sets the events; jobs: run on fresh runners; steps: either run commands or uses actions.
  • actions/checkout gets the code and actions/setup-node installs Node.js; a failing command fails the job.
  • On pull requests, results appear as checks, and branch protection can block merging until they pass.
  • Use a matrix to test several versions and store passwords in Actions secrets, read with ${{ secrets.NAME }}.

Exercise

Add a CI workflow to a repository of your own that has at least one command which can check the code (tests, or even node --check app.js). Make it run on pushes to main and on pull requests. Then open a pull request that deliberately breaks the check, see the red cross, fix it and watch it turn green.

Show solution
YAML
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 26
      - run: node --check app.js

node --check only checks the file's syntax, so a missing bracket in the PR makes the step exit with an error and the check fails. Pushing the fix to the same branch reruns the workflow automatically.

Quick Quiz

1. Where must GitHub Actions workflow files be stored?

2. What does actions/checkout do?

3. What makes a workflow job fail?

4. How should a workflow get a deployment password?

Learn Git with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call