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 | 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.
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 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`: ```text 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 ```
```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" ```
This project builds your first Continuous Integration (CI) pipeline using GitHub Actions. CI means every time someone pu...
Without CI, broken code reaches the main branch constantly. A developer merges a pull request that passes code review bu...
Step 1: Create the Project and Set Up Testing Start with a simple Node.js project that has tests. CI is only valuable wh...
...
Aligns directly with DevOps, Site Reliability (SRE), and Platform Engineering job descriptions.