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
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.
Developer pushes code | vGitHub detects the push | vGitHub Actions triggers(reads .github/workflows/) | +----+----+ | | v vTests Lint Check(jest) (eslint) | | +----+----+ | vBuild Docker Image(verify it compiles) | vAll Green? PR can merge.Any Red? Developer getsan 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.
mkdir github-actions-demo && cd github-actions-demonpm init -ynpm install expressnpm install --save-dev jest supertestCreate src/app.js:
const express = require('express');const app = express(); app.use(express.json()); // Simple calculator API — easy to testapp.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 testingCreate src/server.js (separate from app for clean testing):
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:
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:
{ "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:
npm test## Expected: All 4 tests pass, coverage report shownRememberCI 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
npm install --save-dev eslintnpx eslint --init## Select:## To check syntax and find problems## CommonJS (require/exports)## None of these frameworks## No TypeScript## Node## JSON formatCreate .eslintrc.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" }}## Test lint locallynpx eslint src/## Expected: No errors (we wrote clean code)Step 3: Create the Dockerfile
FROM node:20-alpineWORKDIR /appCOPY package*.json ./RUN npm ci --only=productionCOPY src/ ./src/EXPOSE 3000USER nodeCMD ["node", "src/server.js"]Create .dockerignore:
node_modules.gitcoverage*.test.jsStep 4: Write the GitHub Actions Workflow
This is the heart of the project. Create the directory structure:
mkdir -p .github/workflowsCreate .github/workflows/ci.yml:
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=maxTipThe
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
## Initialise git repositorygit initgit 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.gitgit branch -M maingit push -u origin mainNow 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.
## Introduce a bug — break a testcat >> src/app.js << 'EOF' // Intentionally broken endpointapp.get('/broken', (req, res) => { undefinedFunction(); // This will crash res.json({ ok: true });});EOF ## Also break a test to simulate a real failuresed -i 's/expect(response.body.result).toBe(8)/expect(response.body.result).toBe(999)/' \ src/app.test.js ## Commit and push to a new branchgit checkout -b test/broken-codegit add .git commit -m "test: introducing failures to verify CI blocks them"git push origin test/broken-codeGo 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.
## Fix the broken codegit revert HEADgit push origin test/broken-code## Watch the pipeline re-run and turn greenValidation & Testing
## 1. Run all tests locallynpm test## Expected: All 4 tests pass with coverage report ## 2. Run lint locallynpm 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 runsecho "GitHub Actions CI pipeline fully operational"Videos & Guides
GitHub Actions Tutorial — Complete CI/CD Course
Complete GitHub Actions tutorial covering workflow syntax, jobs, steps, matrix builds, artifacts, secrets, and building a full CI/CD pipeline from scratch.
GitHub Actions — Docker Build and Push Action
Official documentation for the Docker Build and Push GitHub Action — building images with layer caching, multi-platform builds, and pushing to registries.
GitHub Actions Official Documentation
Official GitHub Actions documentation covering workflow syntax, runners, events, contexts, secrets management, and all built-in actions and marketplace integrations.