Time to build a pipeline you'd actually use at work. In this lesson you'll set up ESLint, Jest with coverage, and a build step — wired together in a production-shaped CI workflow.

What Real CI Looks Like

🔍 Lint Code style Static analysis ~15 seconds 🧪 Test Unit tests Code coverage ~30 seconds 📦 Build Compile/package Create artifact ~20 seconds 📊 Report Job summary Status check ~5 seconds ⚡ Parallel Total: ~55 seconds (lint∥test → build → report)
A real CI pipeline: quality checks in parallel, then build, then report

🏋️ Step 1: Set Up Your Project

Upgrade your cicd-mastery repo with real tooling:

cd cicd-mastery

# Add more source code
cat > src/calculator.js << 'EOF'
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function multiply(a, b) {
  return a * b;
}

function divide(a, b) {
  if (b === 0) {
    throw new Error('Division by zero');
  }
  return a / b;
}

module.exports = { add, subtract, multiply, divide };
EOF

# Add comprehensive tests
cat > tests/calculator.test.js << 'EOF'
const { add, subtract, multiply, divide } = require('../src/calculator');

describe('Calculator', () => {
  describe('add', () => {
    test('adds positive numbers', () => expect(add(2, 3)).toBe(5));
    test('adds negative numbers', () => expect(add(-1, -2)).toBe(-3));
    test('adds zero', () => expect(add(5, 0)).toBe(5));
  });

  describe('subtract', () => {
    test('subtracts numbers', () => expect(subtract(5, 3)).toBe(2));
    test('handles negative result', () => expect(subtract(3, 5)).toBe(-2));
  });

  describe('multiply', () => {
    test('multiplies numbers', () => expect(multiply(3, 4)).toBe(12));
    test('multiplies by zero', () => expect(multiply(5, 0)).toBe(0));
  });

  describe('divide', () => {
    test('divides numbers', () => expect(divide(10, 2)).toBe(5));
    test('throws on division by zero', () => {
      expect(() => divide(10, 0)).toThrow('Division by zero');
    });
  });
});
EOF

# Install dev dependencies
npm install --save-dev eslint jest

# Create ESLint config
cat > .eslintrc.json << 'EOF'
{
  "env": { "node": true, "jest": true, "es2021": true },
  "extends": "eslint:recommended",
  "rules": {
    "no-unused-vars": "error",
    "eqeqeq": "error",
    "no-console": "warn"
  }
}
EOF

# Update package.json scripts
npx json -I -f package.json -e '
  this.scripts = {
    "test": "jest",
    "test:coverage": "jest --coverage",
    "lint": "eslint src/ tests/",
    "build": "node scripts/build.js"
  }
'

# Create build script
mkdir -p scripts
cat > scripts/build.js << 'EOF'
const fs = require('fs');
const path = require('path');

const dist = path.join(__dirname, '..', 'dist');
if (fs.existsSync(dist)) fs.rmSync(dist, { recursive: true });
fs.mkdirSync(dist);

// Copy source
fs.readdirSync(path.join(__dirname, '..', 'src')).forEach(f => {
  fs.copyFileSync(
    path.join(__dirname, '..', 'src', f),
    path.join(dist, f)
  );
});

// Write build metadata
const info = {
  version: require('../package.json').version,
  commit: process.env.GITHUB_SHA || 'local',
  time: new Date().toISOString()
};
fs.writeFileSync(path.join(dist, 'build.json'), JSON.stringify(info, null, 2));
console.log('✅ Build complete:', info);
EOF

# Verify everything works locally
npm run lint
npm run test:coverage
npm run build

🏋️ Step 2: The Production CI Workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
    paths-ignore: ['**.md']
  pull_request:
    branches: [main]
  workflow_dispatch:

# Cancel in-progress runs on the same branch
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  # ────────────────────────────────────────
  lint:
    name: 🔍 Lint
    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 run lint

  # ────────────────────────────────────────
  test:
    name: 🧪 Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run tests with coverage
        run: npm run test:coverage
      - name: Coverage summary
        if: always()
        run: |
          echo "## 🧪 Test Results" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "✅ All tests passed with coverage" >> $GITHUB_STEP_SUMMARY

  # ────────────────────────────────────────
  build:
    name: 📦 Build
    needs: [lint, 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 run build
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.sha }}
          path: dist/
          retention-days: 7
      - name: Build summary
        run: |
          echo "## 📦 Build Complete" >> $GITHUB_STEP_SUMMARY
          echo "" >> $GITHUB_STEP_SUMMARY
          echo "Artifact: \`dist-${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
          cat dist/build.json >> $GITHUB_STEP_SUMMARY

New Concepts Introduced

1. cache: 'npm' — Dependency Caching

Without cache npm ci: downloads ALL packages every run (~40s) With cache: 'npm' npm ci: uses cached packages (~8s) ████████████████████ 40s ████ 8s (5× faster)
Built-in caching in setup-node saves 30+ seconds per job. Free performance.

2. concurrency: — Don't Waste Resources

You push 3 commits in 10 seconds: Without concurrency: run 1 ⚡ run 2 ⚡ run 3 ✓ 3 runs, 2 are wasted! With cancel-in-progress: true: run 3 ✓ Only latest runs! Saves minutes.

3. $GITHUB_STEP_SUMMARY — Rich Job Reports

Write Markdown to $GITHUB_STEP_SUMMARY and it appears as a formatted report on the workflow run page. Great for test results, coverage, build info.

🏋️ Step 3: Push & Observe

  1. Commit and push everything
  2. Go to Actions tab — watch lint and test run simultaneously
  3. Click into the run and look at the Summary tab (your step summary renders there)
  4. Download the build artifact
  5. Try breaking a lint rule (e.g., var x = 1; but never use x) — push and watch lint fail, build skip

What You Now Have

You have a CI pipeline that a production team would recognise:
  • ✅ Linting catches style/quality issues
  • ✅ Tests catch logic bugs with coverage tracking
  • ✅ Build only runs if quality gates pass
  • ✅ Artifacts are stored for downstream consumption
  • ✅ Caching makes it fast
  • ✅ Concurrency prevents waste
  • ✅ Summary gives you at-a-glance results

🧠 Recall Check

  1. What does cache: 'npm' in setup-node actually cache?
  2. What does concurrency: cancel-in-progress: true do when you push rapidly?
  3. Why do lint and test run in parallel, but build runs after both?
  4. Where does $GITHUB_STEP_SUMMARY content appear?
Reveal answers
  1. The npm cache directory (~/.npm). This means npm ci resolves packages from local cache instead of downloading from the registry — much faster.
  2. Cancels older in-progress runs for the same branch, keeping only the latest. Saves minutes on rapid pushes.
  3. No needs: on lint/test = parallel. needs: [lint, test] on build = waits for both. You don't want to build if code is broken.
  4. On the workflow run's Summary tab in the GitHub Actions UI. It renders as Markdown.

Next lesson: Caching & Optimization — making your pipeline blazing fast.