Warming up the neural circuits...
By the end of this chapter you will:
Every push to main should trigger a gauntlet: lint, test, build, and — if everything passes — deploy. GitHub Actions makes this automatic, free (within limits), and defined as code right next to your application.
A skyscraper under construction doesn't wait until the 50th floor is done to check if the foundation is solid. At every stage, inspectors verify: concrete cured correctly? Rebar spacing within spec? Electrical up to code? Each floor passes inspection before the next one starts.
is the safety inspector for your code. Every commit triggers a pipeline: linting (is the code well-formed?), testing (does it actually work?), building (can it compile?), and deploying (ship it to users). If any stage fails, the pipeline stops. Bad code never reaches production because it never passed inspection.
GitHub Actions is an automation platform built into GitHub. You define workflows — sequences of jobs triggered by events (push, pull request, schedule, manual trigger). Each job runs on a fresh virtual machine (runner) and executes steps: shell commands or reusable actions from the marketplace.
Key facts:
.github/workflows/*.yml — version-controlled, reviewed, and branched alongside your application code.name: CI Pipeline
# ---- When does it run? ----
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
# ---- What jobs does it run? ----
jobs:
# Job 1: Lint and type-check
lint:
name
Let's break down every section.
onThe on block defines what events kick off the workflow. You can combine multiple triggers:
on:
# On push to specific branches
push:
branches: [main, develop]
tags: ['v*'] # Also on version tags like v1.0.0
# On pull requests targeting main
pull_request:
branches: [main]
types: [opened
on: push without branches: filter runs on EVERY push to EVERY branch. For a monorepo with 20 developers pushing feature branches, this burns through your free minutes and creates noise. Always scope with branches: or branches-ignore:.
A job is a unit of work that runs on a single runner. A step is an individual command or action within a job.
jobs:
my-job:
runs-on: ubuntu-latest # Runner type
timeout-minutes: 10 # Kill if it takes longer
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run a command
run: echo "Hello"
shell: bash
- name
Jobs run in parallel by default. Use needs to create dependencies:
jobs:
lint:
# Runs immediately
test:
# Runs immediately, parallel with lint
build:
needs: [lint, test] # Waits for BOTH lint AND test to succeed
deploy:
needs: [build] # Waits for buildThis creates a pipeline:
lint ──┐
├── build ── deploy
test ──┘Actions are the npm packages of CI/CD. Instead of writing complex logic yourself, you use community or official actions:
steps:
# Checkout your code (THE most-used action)
- uses: actions/checkout@v4
# Set up Node.js
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
# Set up Docker Buildx (for multi-platform builds)
- uses: docker/setup-buildx-action@v3
# Login to Docker Hub
Always pin actions to a major version (@v4) for stability. The action author can ship non-breaking updates to the major version tag.
Secrets are encrypted values stored in GitHub (Settings → Secrets and variables → Actions). They're injected at runtime and never appear in logs:
steps:
- name: Deploy
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
scp -r dist/ user@${{ secrets.SERVER_IP }}:/opt/app/GitHub automatically redacts secret values from logs — but ONLY if the exact string appears. If you echo $SECRET | base64 and that base64 string leaks the secret indirectly, it won't be redacted. Never print secrets or their transformations. Use add-mask for dynamic secrets.
Without caching, every CI run downloads dependencies from scratch. actions/setup-node@v4 has built-in caching:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm' # Automatically caches ~/.npmFor other caching needs (e.g., layers, build outputs):
- uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-The cache key strategy: if package-lock.json hasn't changed, the exact key matches and you get a cache hit. If it has changed, restore-keys provides a partial match (same OS, different lockfile). This saves 30-90% of CI time.
You want to know your code works on Node 18, 20, AND 22 — not just the one you develop on. Matrix builds run the same job with different parameters:
jobs:
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
# Can add more dimensions:
# os: [ubuntu-latest, windows-latest]
# db: [postgres, mysql]
steps:
- uses:
This spawns 3 parallel jobs — one for each Node version. If Node 22 tests pass but Node 18 fails, you've found a compatibility bug before your users did.
A matrix with os: [ubuntu, windows, macos] × node: [18, 20, 22] × db: [postgres, mysql] = 18 parallel jobs. That's powerful but can burn through free minutes fast. Use fail-fast: false to let all jobs complete even if one fails — you want to see which specific combination broke.
Real pipelines have multiple environments. Use environment protection rules:
jobs:
deploy-staging:
runs-on: ubuntu-latest
environment: staging
if: github.ref == 'refs/heads/develop'
steps:
- run: ./deploy.sh staging
deploy-production:
runs-on: ubuntu-latest
environment: production
if: github.ref == 'refs/heads/main'
needs: [deploy-staging]
steps:
-Environment protection rules (configured in GitHub repo settings):
Each job runs on a fresh runner. To pass build output from one job to another, use artifacts:
jobs:
build:
steps:
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
retention-days: 7
deploy:
needs: [build]
steps:
- uses: actions/download-artifact@v4
Let's put it all together — a real-world CI/CD workflow for a Dockerized Node.js app:
name: Build, Test & Deploy
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
#
This pipeline:
A project management SaaS with 15 developers was deploying to production 3-4 times a day. Before GitHub Actions, their "pipeline" was:
main.npm test on their machine (if they remember).git pull, npm install, pm2 restart.Problems:
They implemented the pipeline above. Results after 3 months:
main went through lint → test (3 Node versions) → build → deploy. Any failure blocked the pipeline.The key insight: CI/CD isn't just about speed — it's about making the right thing (testing before deploy) the easy thing (automatic), and the wrong thing (skipping tests) impossible.
| Mistake | Why it's wrong | Fix |
|---|---|---|
No branches: filter on push | Workflow runs on every push to every branch — wastes minutes, clutters logs | Scope with branches: [main, develop] or branches-ignore: ['feature/*'] |
| Hardcoding secrets in workflow YAML | Committed to git — anyone with repo access can see them | Use ${{ secrets.SECRET_NAME }} — stored encrypted in GitHub |
| Not caching dependencies | Every CI run downloads the internet from scratch — slow and wasteful | Use cache: 'npm' in setup-node or actions/cache@v4 |
fail-fast: true (default) with matrix builds | One failing Node version cancels all other matrix jobs — you don't know which others also failed | Set fail-fast: false in strategy to let all matrix jobs complete |
| Running deploy on PR events | PR from a fork could trigger a deploy if not gated | Check if: github.event_name == 'push' && github.ref == 'refs/heads/main' |
Using actions/checkout@main instead of |
github.ref and github.event_name for conditional logic. Deploy only from main, not from feature branches. Preview deploys from PR branches if you have the infrastructure.concurrency group to prevent race conditions. concurrency: group: ${{ github.workflow }}-${{ github.ref }} ensures only one workflow runs per branch at a time. A new push cancels the in-progress run for the same branch.cache-from: type=gha and cache-to: type=gha,mode=max in docker/build-push-action saves minutes per build.paths filters to skip irrelevant workflows. If only docs changed, don't run the full test suite: on: push: paths: ['src/**', 'package.json', 'package-lock.json'].restore-keys in actions/cache lets you reuse a cache even when the primary key doesn't match exactly — partial hits are better than cache misses.docker compose. For simple services (PostgreSQL for tests), use services: in the job definition. It's faster than docker compose up because the runner manages the lifecycle.pull_request_target for untrusted code. This event runs in the context of the BASE repository (access to secrets) even for PRs from forks. Attackers can exfiltrate secrets. Use pull_request (lowercase) which runs in the fork's context.actions/*, github/*, docker/*) and well-known community actions.GITHUB_TOKEN with minimal permissions. Add permissions: block at the workflow or job level: contents: read is enough for most jobs; only packages: write for Docker push jobs.Beginner:
.github/workflows/ci.yml that runs on every push to main. It should checkout code, set up Node.js, install dependencies, and run npm test. Push it and verify the workflow runs in the Actions tab.Intermediate:
fail-fast: false. Verify all 3 jobs complete even if one fails.Advanced:
concurrency groups, timeout-minutes, and artifact passing.Beginner:
Q: What is CI/CD? A: CI (Continuous Integration) is the practice of automatically building and testing code every time it's pushed to a shared repository. CD (Continuous Delivery/Deployment) extends that to automatically deploying passing builds to staging or production. The goal: catch problems early, deploy safely and frequently.
Q: What's the structure of a GitHub Actions workflow file?
A: A YAML file with name, on (triggers), and jobs. Each job has runs-on (runner type), steps (commands or actions), and optional needs (dependencies). Jobs without needs run in parallel; jobs with needs wait for their dependencies to succeed.
Q: How do you pass secrets to a GitHub Actions workflow?
A: Store them in GitHub Settings → Secrets and variables → Actions. Reference them in the workflow with ${{ secrets.SECRET_NAME }}. Secrets are encrypted at and automatically redacted from logs.
Senior:
Q: How do you design a CI/CD pipeline that prevents broken code from reaching production while keeping deploy times under 5 minutes?
A: Layer the pipeline by speed and criticality: (1) Fast checks first — linting, formatting, type checking (1-2 min). These catch 80% of issues. (2) Unit tests run in parallel with matrix builds (2-3 min). (3) Integration tests with service containers (3-4 min). (4) Build Docker image with layer caching (1-2 min). (5) Deploy to staging automatically, run smoke tests (1 min). (6) Production deploy gated by manual approval or automated canary analysis. Use caching aggressively (npm, Docker layers, build artifacts). Skip irrelevant jobs with paths: filters. Set concurrency to cancel redundant runs. The key insight: developers should get feedback on lint/test failures within 2 minutes; the full pipeline completes in under 5 minutes for most changes.
Q: What's the security risk of pull_request_target vs pull_request, and when would you use each?
A: pull_request_target runs in the context of the BASE repository — it has access to repository secrets and write permissions, even for PRs from forks. This is dangerous: a malicious PR from a fork could exfiltrate secrets or modify the repo. Only use pull_request_target for workflows that need secret access AND you've explicitly checked out the PR code safely (e.g., labeling PRs, commenting). pull_request runs in the fork's context with read-only access to the base repo — safe for building and testing PR code. For 95% of cases, use pull_request. Use pull_request_target only for trusted automation that doesn't execute PR code (e.g., ).
GitHub Actions automates your quality gates and deployments, defined as YAML workflows living right alongside your code. A workflow triggers on events (push, PR, schedule, manual), runs jobs in parallel or sequence, and executes steps — shell commands or reusable community actions. Secrets are encrypted and injected at runtime. Caching (npm, Docker layers, build artifacts) turns 10-minute pipelines into 2-minute ones. Matrix builds test your code across multiple Node versions, operating systems, and database versions simultaneously. Environment protection rules gate production deploys behind manual approval. The complete pattern: lint → test (matrix) → build Docker image → deploy to staging automatically → deploy to production with approval. CI/CD isn't about speed alone — it makes the right thing (testing before deploy) automatic and the wrong thing (skipping tests) impossible.
.github/workflows/*.yml — YAML with name, on, jobs.push, pull_request, schedule (cron), workflow_dispatch (manual).needs: [jobA, jobB] creates dependencies.uses: (actions) or run: (shell commands).${{ secrets.NAME }} — stored in GitHub, redacted from logs.actions/cache@v4 or built-in cache: 'npm' in setup-node.strategy: matrix: node-version: [18, 20, 22] — parallel jobs per combination..yml (or .yaml), stored in .github/workflows/.needs: [other-job-name] in the dependent job definition.${{ secrets.SECRET_NAME }}.fail-fast: false in a matrix strategy? Answer: So that one failing matrix job doesn't cancel all others — you want to see which specific combinations fail.pull_request and pull_request_target? Answer: pull_request runs in the fork's context without secret access (safe for PRs from forks). pull_request_target runs in the base repo's context with secret access (dangerous — only for trusted automation).timeout-minutes: 15 in the job definition.@v4main branch of the action can introduce breaking changes silently |
Pin to major version: actions/checkout@v4 |
No timeout-minutes on jobs | A hung test can run for 6 hours (the default max), burning all your free minutes | Set timeout-minutes: 15 on every job |
actions/labelerQ: How do you handle database migrations in a CI/CD pipeline with zero-downtime deploys?
A: The strategy depends on the migration type: (1) Additive migrations (new tables, new columns with defaults) — run BEFORE deploy. Old code ignores new columns; new code uses them. Safe. (2) Destructive migrations (drop columns, rename) — requires a multi-step process: (a) Deploy code that stops using the old column. (b) Run migration to drop the column. (c) Never drop a column in the same deploy that stops using it. (3) Expensive migrations (adding indexes to large tables) — use CONCURRENTLY (PostgreSQL) and run outside the deploy pipeline; they can take hours and shouldn't block deploys. In the pipeline itself: run migrations as a separate job before deploy, with a lock or advisory lock to prevent concurrent migration runs. If the migration fails, abort the deploy. Have a rollback plan: for every migration, write and test the down migration BEFORE you deploy the up migration.
upload-artifactdownload-artifactconcurrency: Prevents race conditions — cancels in-progress runs for the same branch.