Build Your First CI Pipeline with GitHub Actions

Create a GitHub Actions workflow that automatically runs tests, checks code quality, and builds a Docker image on every pull request and push to main.

Domains & Technologies

Domains
CI-CD
Technologies
DOCKERGITHUB

Blueprint Walkthrough

Architecture Overview

This project builds your first Continuous Integration (CI) pipeline using GitHub Actions. CI means every time someone pushes code or opens a pull request, automated checks run instantly — tests pass, code quality is verified, and a Docker image is built. If anything fails, the developer knows immediately before the bad code reaches production.

GitHub Actions is built directly into GitHub — no separate server to set up, no Jenkins to install. You write a YAML file, push it to your repository, and GitHub runs it automatically.

◈ DIAGRAM
Developer pushes code
|
v
GitHub detects the push
|
v
GitHub Actions triggers
(reads .github/workflows/)
|
+----+----+
| |
v v
Tests Lint Check
(jest) (eslint)
| |
+----+----+
|
v
Build Docker Image
(verify it compiles)
|
v
All Green? PR can merge.
Any Red? Developer gets
an email notification.
Problem Solved

Without CI, broken code reaches the main branch constantly. A developer merges a pull request that passes code review but breaks tests. Nobody knows until a colleague pulls the latest code and their local environment stops working. Finding and fixing the broken commit wastes hours.

With GitHub Actions CI, the broken PR is caught automatically before merge. GitHub shows a red X on the pull request — "Tests failed — 3 of 8 tests failing." The developer fixes the issue, pushes again, and GitHub re-runs the checks. The PR can only merge once everything is green.

Step-by-Step Implementation Guide

Step 1: Create the Project and Set Up Testing

Start with a simple Node.js project that has tests. CI is only valuable when you have tests to run.

Bash
mkdir github-actions-demo && cd github-actions-demo
npm init -y
npm install express
npm install --save-dev jest supertest

Create src/app.js:

JAVASCRIPT
const express = require('express');
const app = express();
app.use(express.json());
// Simple calculator API — easy to test
app.post('/add', (req, res) => {
const { a, b } = req.body;
if (typeof a !== 'number' || typeof b !== 'number') {
return res.status(400).json({ error: 'a and b must be numbers' });
}
res.json({ result: a + b });
});
app.post('/multiply', (req, res) => {
const { a, b } = req.body;
if (typeof a !== 'number' || typeof b !== 'number') {
return res.status(400).json({ error: 'a and b must be numbers' });
}
res.json({ result: a * b });
});
app.get('/health', (req, res) => {
res.json({ status: 'healthy' });
});
module.exports = app; // Export for testing

Create src/server.js (separate from app for clean testing):

JAVASCRIPT
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

Create src/app.test.js:

JAVASCRIPT
const request = require('supertest');
const app = require('./app');
describe('Calculator API', () => {
// Test the /add endpoint
test('adds two numbers correctly', async () => {
const response = await request(app)
.post('/add')
.send({ a: 5, b: 3 });
expect(response.status).toBe(200);
expect(response.body.result).toBe(8);
});
test('rejects non-number inputs for add', async () => {
const response = await request(app)
.post('/add')
.send({ a: 'five', b: 3 });
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
// Test the /multiply endpoint
test('multiplies two numbers correctly', async () => {
const response = await request(app)
.post('/multiply')
.send({ a: 4, b: 7 });
expect(response.status).toBe(200);
expect(response.body.result).toBe(28);
});
// Test the health endpoint
test('health check returns healthy', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
expect(response.body.status).toBe('healthy');
});
});

Update package.json to add the test script and jest configuration:

JSON
{
"name": "github-actions-demo",
"version": "1.0.0",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"test": "jest --coverage",
"test:watch": "jest --watch",
"lint": "eslint src/"
},
"jest": {
"testEnvironment": "node",
"collectCoverageFrom": ["src/**/*.js", "!src/**/*.test.js"]
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"jest": "^29.0.0",
"supertest": "^6.3.0",
"eslint": "^8.0.0"
}
}

Verify tests pass locally before setting up CI:

Bash
npm test
## Expected: All 4 tests pass, coverage report shown
Remember

CI is only as useful as your tests. If you have no tests, CI just verifies the code starts up. Start with even a few basic tests — they catch far more bugs than you expect.

Step 2: Set Up ESLint for Code Quality

Bash
npm install --save-dev eslint
npx eslint --init
## Select:
## To check syntax and find problems
## CommonJS (require/exports)
## None of these frameworks
## No TypeScript
## Node
## JSON format

Create .eslintrc.json:

JSON
{
"env": {
"node": true,
"es2021": true,
"jest": true
},
"extends": "eslint:recommended",
"rules": {
"no-unused-vars": "error",
"no-console": "warn",
"eqeqeq": "error",
"no-var": "error",
"prefer-const": "error"
}
}
Bash
## Test lint locally
npx eslint src/
## Expected: No errors (we wrote clean code)

Step 3: Create the Dockerfile

Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src/ ./src/
EXPOSE 3000
USER node
CMD ["node", "src/server.js"]

Create .dockerignore:

Bash
node_modules
.git
coverage
*.test.js

Step 4: Write the GitHub Actions Workflow

This is the heart of the project. Create the directory structure:

Bash
mkdir -p .github/workflows

Create .github/workflows/ci.yml:

YAML
name: CI Pipeline
## When does this pipeline run?
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
jobs:
# ----------------------------------------
# Job 1: Run Tests with Coverage
# ----------------------------------------
test:
name: Run Tests
runs-on: ubuntu-latest # GitHub provides this machine for free
strategy:
matrix:
# Test on multiple Node.js versions simultaneously
node-version: [18.x, 20.x]
steps:
# Step 1: Download your repository code onto the runner
- name: Checkout Code
uses: actions/checkout@v4
# Step 2: Install the correct Node.js version
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm' # Cache node_modules between runs
# Step 3: Install dependencies
# npm ci is stricter than npm install — uses exact lock file versions
- name: Install Dependencies
run: npm ci
# Step 4: Run tests with coverage report
- name: Run Tests
run: npm test
# Step 5: Upload coverage report as an artifact
# Download it from the Actions tab after the run
- name: Upload Coverage Report
uses: actions/upload-artifact@v4
if: matrix.node-version == '20.x' # Only upload once
with:
name: coverage-report
path: coverage/
retention-days: 7
# ----------------------------------------
# Job 2: Lint Check
# ----------------------------------------
lint:
name: Lint Check
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: Install Dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
# ----------------------------------------
# Job 3: Build Docker Image
# ----------------------------------------
build:
name: Build Docker Image
runs-on: ubuntu-latest
# Only run this job after tests AND lint pass
needs: [test, lint]
steps:
- name: Checkout Code
uses: actions/checkout@v4
# Build metadata (generates tags and labels)
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: YOUR_DOCKERHUB_USERNAME/github-actions-demo
tags: |
type=ref,event=branch
type=sha,prefix=sha-
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
# Build the image (but do NOT push — just verify it builds)
- name: Build Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: false # Just build, no push for now
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # Use GitHub Actions cache
cache-to: type=gha,mode=max
Tip

The needs: [test, lint] in the build job means the Docker image is only built if tests and lint both pass. This prevents wasting time building an image from broken code.

Step 5: Push to GitHub and See It Run

Bash
## Initialise git repository
git init
git add .
git commit -m "feat: initial project with GitHub Actions CI"
## Create a repository on github.com first, then:
git remote add origin https://github.com/YOUR_USERNAME/github-actions-demo.git
git branch -M main
git push -u origin main

Now go to your GitHub repository -> click the Actions tab -> watch your pipeline run in real time.

Step 6: Test That CI Actually Catches Failures

This is the most important step — verify the pipeline blocks bad code.

Bash
## Introduce a bug — break a test
cat >> src/app.js << 'EOF'
// Intentionally broken endpoint
app.get('/broken', (req, res) => {
undefinedFunction(); // This will crash
res.json({ ok: true });
});
EOF
## Also break a test to simulate a real failure
sed -i 's/expect(response.body.result).toBe(8)/expect(response.body.result).toBe(999)/' \
src/app.test.js
## Commit and push to a new branch
git checkout -b test/broken-code
git add .
git commit -m "test: introducing failures to verify CI blocks them"
git push origin test/broken-code

Go to GitHub -> create a pull request from test/broken-code to main. Watch the CI pipeline run — it will show red X marks on the failing test and lint jobs. The pull request cannot be merged while checks are failing.

Bash
## Fix the broken code
git revert HEAD
git push origin test/broken-code
## Watch the pipeline re-run and turn green
Validation & Testing
Bash
## 1. Run all tests locally
npm test
## Expected: All 4 tests pass with coverage report
## 2. Run lint locally
npm run lint
## Expected: No errors or warnings
## 3. Check GitHub Actions ran successfully
## Go to github.com/YOUR_USERNAME/github-actions-demo/actions
## Expected: Green checkmarks on all 3 jobs
## 4. Verify the matrix ran on both Node versions
## Expected: See Run Tests (18.x) and Run Tests (20.x) both passing
## 5. Download the coverage artifact
## Actions tab -> your run -> Artifacts -> coverage-report
## Open coverage/index.html in browser to see which lines are covered
## 6. Verify PR protection is working
## Settings -> Branches -> Add rule -> main
## Check: Require status checks to pass before merging
## Select your CI jobs as required checks
## Now bad code literally cannot merge without fixing CI
## 7. Check pipeline speed
## Expected: Full pipeline completes in under 2 minutes
## The npm caching step saves ~30 seconds on subsequent runs
echo "GitHub Actions CI pipeline fully operational"