Writing a GitHub Actions Workflow
A GitHub Actions workflow is a YAML file that tells GitHub what to do automatically whenever something happens in your repository — for example, when you push code or open a pull request. CI (Continuous Integration — the practice of merging and automatically testing code changes often) lives in these files. In this page you will build a real, copy-pasteable workflow that installs dependencies, runs your tests, and builds your project, using a Node.js example. By the end you will understand jobs, steps, marketplace actions, caching, and matrix builds.
Where the workflow file lives
GitHub looks for workflow files in a special folder inside your repository: .github/workflows/. Any .yml or .yaml file you put there becomes a workflow. You can have many workflow files (one for tests, one for deploys, and so on).
Create the folder and an empty file from your project root on Ubuntu:
mkdir -p .github/workflows
touch .github/workflows/ci.yml
Output:
$ ls -R .github
.github:
workflows
.github/workflows:
ci.yml
The path must be exactly
.github/workflows/. A typo like.github/workflow/(no “s”) means GitHub silently ignores your file and nothing runs. This is the single most common beginner mistake.
The building blocks: workflow, jobs, steps
Three words appear everywhere, so define them once:
| Term | What it means | Analogy |
|---|---|---|
| Workflow | The whole YAML file and everything it does | A recipe |
| Job | A named group of steps that runs on one fresh machine (a “runner”) | One stage of cooking |
| Step | A single command or action inside a job | One instruction in that stage |
| Action | A reusable, pre-packaged step you pull from the Marketplace | A kitchen gadget you didn’t build yourself |
A runner is a clean virtual machine GitHub gives you for free (Ubuntu, Windows, or macOS) to run each job. Each job starts on its own brand-new runner, so jobs do not share files unless you explicitly pass things between them.
A complete CI workflow
Here is a full, working workflow. Save this as .github/workflows/ci.yml. It triggers on pushes and pull requests to main, then installs, lints, tests, and builds a Node.js project.
name: CI
# When should this run?
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build-and-test:
name: Build and test
runs-on: ubuntu-24.04 # the runner OS
steps:
# 1. Get your code onto the runner
- name: Check out code
uses: actions/checkout@v4
# 2. Install the Node.js version you want
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm" # cache the npm download cache
# 3. Install dependencies exactly as locked
- name: Install dependencies
run: npm ci
# 4. Run your linter
- name: Lint
run: npm run lint
# 5. Run your tests
- name: Run tests
run: npm test
# 6. Build the project
- name: Build
run: npm run build
Once you commit and push this file, open the Actions tab on GitHub. You will see the workflow running with each step expandable so you can read its logs.
What each piece does
name:is just the label shown in the GitHub UI.on:lists the triggers (events that start the workflow). Here, any push or pull request targetingmain.runs-on:picks the runner OS. Pin a specific version likeubuntu-24.04rather thanubuntu-latestso your builds do not silently change when GitHub upgrades the default.uses:pulls in a Marketplace action.run:runs a raw shell command instead.
When to use npm ci vs npm install: Use npm ci in CI. It installs the exact versions from package-lock.json and deletes node_modules first, giving a clean, reproducible install. Use npm install only locally when you actually want to add or update packages.
Marketplace actions and pinning versions
The GitHub Marketplace is a library of ready-made actions other people published. Two you will use constantly:
actions/checkout— copies your repository onto the runner. Almost every job needs it first.actions/setup-node— installs a chosen Node.js version and sets up caching.
The @v4 after the action name is the version tag. You should always pin a version. Three ways, from convenient to most secure:
| Pin style | Example | When to use |
|---|---|---|
| Major tag | actions/checkout@v4 | Most projects — gets safe minor updates automatically |
| Exact tag | actions/[email protected] | When you need fully repeatable builds |
| Commit SHA | actions/checkout@b4ffde6... | Security-sensitive repos — a tag can be moved, a SHA cannot |
Never use
@mainor@masterfor an action you do not own. The author could push new code at any moment and it would run with access to your repository and secrets. Pin to a tag or SHA so you control exactly what executes.
Caching dependencies
Downloading dependencies on every run wastes minutes. Caching saves a folder after one run and restores it on the next, so unchanged dependencies download once and are reused.
The easy way is the cache: "npm" line already in setup-node above — it caches npm’s download cache and keys it off your package-lock.json. When the lock file changes, the cache automatically refreshes.
If you need finer control (for example, caching node_modules itself), use the cache action directly:
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
The key is the exact name of the cache. hashFiles(...) turns your lock file into a fingerprint, so a new lock file produces a new key and a fresh install. restore-keys is a fallback prefix used when no exact match exists.
Matrix builds — test on many versions at once
A matrix runs the same job several times with different inputs. The classic use is testing across multiple Node.js versions to catch version-specific bugs before users do.
jobs:
test:
name: Test on Node ${{ matrix.node }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false # let all versions finish even if one fails
matrix:
node: ["20", "22", "24"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: "npm"
- run: npm ci
- run: npm test
This launches three parallel jobs — Node 20, 22, and 24 — each on its own runner. Note every matrix value is quoted as a string; node-version: 20 (unquoted) can be misread as a number and pull the wrong version.
When to use a matrix: Use it for libraries and tools that must support several runtimes or operating systems. Skip it for a simple app deployed on one fixed Node version — it just burns build minutes for no benefit.
Best Practices
- Pin every action to a version tag or commit SHA; never trust a floating branch.
- Use
npm ci(notnpm install) so CI installs exactly what your lock file specifies. - Enable dependency caching to keep runs fast and cut your billed minutes.
- Pin
runs-on:to a specific OS version (ubuntu-24.04) for reproducible builds. - Trigger on
pull_requestso problems are caught before code merges intomain. - Keep secrets out of YAML — reference them with
${{ secrets.NAME }}, never paste tokens. - Give jobs and steps clear
name:labels so failing logs are easy to scan.