GitHub Actions: Build Your First CI/CD Pipeline
Automatically test, build, and deploy your code on every push. Set up a working pipeline from scratch without reading the entire Actions documentation.
Every time someone pushes code, GitHub Actions can run your tests, build your app, and deploy it — automatically, in a clean environment, with results posted directly to the pull request. This tutorial builds a working pipeline step by step, explaining what each part actually does.
How it works
Workflows are YAML files in .github/workflows/. GitHub detects them automatically — no registration or webhook setup required. A workflow runs on a fresh virtual machine spun up by GitHub each time it is triggered.
The structure:
name: CI
on: [push] # when to run
jobs:
test: # job name
runs-on: ubuntu-latest # VM type
steps:
- uses: actions/checkout@v4 # an action (reusable step)
- run: npm test # a shell command
That is the whole model. on sets the trigger, jobs defines what to do, steps are the individual commands and reusable actions.
Step 1 — A basic test pipeline
Create .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main, develop]
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
Push this file to your repo. GitHub runs it immediately. The actions/checkout@v4 step clones your repo into the VM. actions/setup-node@v4 installs Node and sets up dependency caching so subsequent runs are faster. npm ci is faster and more deterministic than npm install in CI contexts.
Step 2 — Matrix builds
Test against multiple versions with a matrix:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
- run: npm ci
- run: npm test
This runs three parallel jobs — one per Node version. All three must pass for the workflow to succeed. GitHub shows each run independently in the UI.
Step 3 — Add a build step
- name: Build
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
upload-artifact saves the build output so a later deployment job can download it without rebuilding from scratch.
Step 4 — A deploy job that runs after tests pass
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- run: npm ci
- run: npm test
- run: npm run build
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist/ }
deploy:
runs-on: ubuntu-latest
needs: test # only runs if test job passes
if: github.ref == 'refs/heads/main' # only on pushes to main
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy
run: ./scripts/deploy.sh
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
needs: test creates a dependency — the deploy job only starts after test succeeds. if: github.ref == 'refs/heads/main' ensures deploys only happen on the main branch, not on every feature branch push.
Secrets
Never put credentials in workflow files. Store them in your repository's Settings → Secrets and variables → Actions.
Reference them in workflows as ${{ secrets.SECRET_NAME }}. GitHub masks their values in log output automatically.
Caching dependencies
The cache: "npm" option on setup-node handles caching for you. For other scenarios:
- name: Cache node_modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
The cache key includes the lockfile hash — the cache is invalidated exactly when dependencies change, not before or after.
Workflow status badge
Add a status badge to your README:

It shows green when the latest run on the default branch passed, red when it failed.
Common problems
npm ci fails with a lockfile error. The package-lock.json is out of sync with package.json. Run npm install locally, commit the updated lockfile.
Deploy job runs on every branch. Add if: github.ref == 'refs/heads/main' to the deploy job to gate it to the main branch only.
Secrets are not available in pull requests from forks. This is intentional — untrusted code running in a fork cannot access your secrets. Keep secrets out of the test job and limit them to the deploy job, which does not run from forks.
SysEmperor