Continuous Integration and Continuous Deployment (CI/CD) transforms software delivery from a manual, error-prone process into an automated, reliable pipeline. Every code push triggers automated tests, security scans, image builds, and deployments — catching bugs before they reach production, enforcing quality standards, and enabling multiple deployments per day with confidence. GitHub Actions is the native CI/CD platform for GitHub repositories, providing free compute for public repos and generous allowances for private ones, with a YAML-based workflow syntax that lives alongside your code.
GitHub Actions Core Concepts
| Concept | Definition | Example |
|---|---|---|
| Workflow | An automated process defined in a YAML file in .github/workflows/ |
ci.yml, deploy.yml |
| Trigger (on) | Event that starts a workflow | push, pull_request, schedule, workflow_dispatch |
| Job | A group of steps that run on the same runner (virtual machine) | test, build, deploy |
| Step | An individual task within a job — a shell command or action | run: npm test, uses: actions/checkout@v4 |
| Runner | The virtual machine that executes a job | ubuntu-latest, macos-latest, windows-latest |
| Action | Reusable, composable unit of automation from the GitHub Marketplace | actions/checkout, docker/build-push-action |
| Secret | Encrypted variable stored in repo/org settings — injected at runtime | secrets.JWT_SECRET, secrets.DOCKERHUB_TOKEN |
| Environment | Named deployment target with protection rules and secrets | staging, production |
| Matrix | Run a job multiple times with different parameter combinations | Test on Node 18, 20, 22 simultaneously |
| Artifact | File produced by a job and uploaded for use by later jobs | Test coverage reports, build outputs |
.github/workflows/ at the root of your repository. Each YAML file defines one workflow. You can have multiple workflows that trigger on different events — a ci.yml that runs on every push to any branch, a deploy-staging.yml that runs on pushes to develop, and a deploy-production.yml that runs on releases. Workflows can also call other workflows as reusable components with uses: ./.github/workflows/reusable.yml.@v4. A tag can be moved to point to malicious code — this is a supply chain attack vector. Using actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 (the SHA for v4.1.1) ensures you always run exactly the code you audited, even if the v4 tag is later moved. Many security-conscious organisations require SHA pinning in CI/CD workflows.run: echo ${{ secrets.JWT_SECRET }} exposes the secret in the Actions log (which may be accessible to collaborators). GitHub automatically redacts known secret values from logs, but multi-character patterns may still leak partial information. Keep secrets in GitHub Secrets, access them only through the secrets context, and never assign them to environment variables in log-visible steps.Complete CI Workflow — Pull Request Quality Gate
# .github/workflows/ci.yml
# Runs on every push and pull request — the quality gate before merging
name: CI
on:
push:
branches: [main, develop, 'feature/**']
pull_request:
branches: [main, develop]
# Cancel in-progress runs for the same branch/PR (saves CI minutes)
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
NODE_VERSION: '20'
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ── Job 1: Lint and type check ────────────────────────────────────────
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: api/package-lock.json
- name: Install API dependencies
run: npm ci
working-directory: api
- name: Lint API
run: npm run lint
working-directory: api
- name: Type check API
run: npm run typecheck
working-directory: api
- name: Setup Node.js for Angular
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install Angular dependencies
run: npm ci
working-directory: client
- name: Lint Angular
run: npm run lint
working-directory: client
# ── Job 2: Test API ───────────────────────────────────────────────────
test-api:
name: Test API
runs-on: ubuntu-latest
# Run lint first — skip tests if linting fails
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: api/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: api
- name: Run unit tests with coverage
run: npm run test:coverage
working-directory: api
env:
JWT_SECRET: test-secret-for-ci
REFRESH_SECRET: test-refresh-secret
NODE_ENV: test
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: api-coverage
path: api/coverage/
retention-days: 7
- name: Check coverage thresholds
run: |
COVERAGE=$(cat api/coverage/coverage-summary.json | \
node -e "const d=require('/dev/stdin'); \
console.log(d.total.lines.pct)")
echo "Line coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 80" | bc -l) )); then
echo "Coverage ${COVERAGE}% is below 80% threshold"
exit 1
fi
# ── Job 3: Test Angular ────────────────────────────────────────────────
test-angular:
name: Test Angular
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: client
- name: Run Angular tests (headless)
run: npm run test:ci
working-directory: client
# test:ci = ng test --watch=false --browsers=ChromeHeadless
# ── Job 4: Security audit ─────────────────────────────────────────────
security:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Audit API dependencies
run: npm audit --audit-level=high
working-directory: api
- name: Audit Angular dependencies
run: npm audit --audit-level=high
working-directory: client
# ── Job 5: Build check (Angular production build) ────────────────────
build-check:
name: Build Check
runs-on: ubuntu-latest
needs: [test-api, test-angular]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install Angular dependencies
run: npm ci
working-directory: client
- name: Production build
run: npm run build -- --configuration production
working-directory: client
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: angular-build
path: client/dist/
retention-days: 1
How It Works
Step 1 — Triggers Define When Workflows Run
The on: section defines which GitHub events start the workflow. push: branches: [main, develop] runs on direct pushes to those branches. pull_request: branches: [main] runs when a PR is opened or updated targeting main. workflow_dispatch adds a manual trigger button in the GitHub UI. schedule: - cron: '0 2 * * *' runs nightly at 2am — useful for security scans and dependency updates.
Step 2 — needs Creates a Dependency Graph Between Jobs
Jobs run in parallel by default. needs: lint makes a job wait for lint to succeed before starting. needs: [test-api, test-angular] waits for both. This creates a DAG (directed acyclic graph) of jobs — lint first, then tests in parallel, then build only if both tests pass. If any dependency fails, downstream jobs are skipped (not just failed). This is more efficient than serial jobs and cheaper than running all checks in one mega-job.
Step 3 — concurrency Cancels Superseded Runs
concurrency: group: ci-${{ github.ref }} groups all runs for the same branch under one concurrency key. When a new push to feature/auth starts a CI run while a previous one is still running, the previous run is cancelled. This prevents CI queues from backing up and avoids wasting compute minutes on runs whose results are already obsolete. The group key using github.ref ensures only runs for the same branch cancel each other.
Step 4 — actions/setup-node cache Speeds Up Dependency Installation
The cache: npm option on actions/setup-node caches the npm cache directory between runs. On cache hit, npm ci can resolve packages from the cache rather than downloading from the registry — turning a 60-second install into a 10-second restore. The cache key is derived from the package-lock.json hash — so the cache is invalidated whenever dependencies change.
Step 5 — Artifacts Pass Data Between Jobs
upload-artifact saves files from one job; download-artifact retrieves them in a later job. The build job uploads the Angular dist/ folder, which the deploy job downloads and pushes to the server — without rebuilding. Coverage reports are uploaded for the PR comment bot to display. Artifacts are stored for a defined retention period and are accessible in the GitHub Actions UI for download and inspection.
Common Mistakes
Mistake 1 — Running all steps in one giant job
❌ Wrong — everything sequential, no parallelism, slow feedback:
jobs:
everything:
steps:
- run: npm run lint
- run: npm test # waits for lint even if unrelated
- run: npm run build # waits for both even if only checking build
✅ Correct — parallel jobs with dependency graph:
jobs:
lint: ...
test: { needs: lint } # parallel once lint passes
build: { needs: test } # only after tests pass
Mistake 2 — Hardcoding secrets in workflow YAML
❌ Wrong — secret visible in git history forever:
env:
JWT_SECRET: my-super-secret-key # IN THE REPO — visible to everyone!
✅ Correct — reference from GitHub Secrets:
env:
JWT_SECRET: ${{ secrets.JWT_SECRET }} # encrypted in GitHub, never in code
Mistake 3 — Not caching dependencies
❌ Wrong — npm ci downloads all packages from network on every run:
- uses: actions/setup-node@v4
with:
node-version: '20'
# Missing: cache: npm
✅ Correct — enable npm caching:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: api/package-lock.json
Quick Reference
| Task | YAML Syntax |
|---|---|
| Trigger on push to main | on: push: branches: [main] |
| Trigger on PR | on: pull_request: branches: [main] |
| Manual trigger | on: workflow_dispatch: |
| Scheduled run | on: schedule: - cron: '0 2 * * 1' |
| Job depends on other | needs: [lint, test] |
| Cancel superseded runs | concurrency: group: ${{ github.ref }}, cancel-in-progress: true |
| Use a secret | ${{ secrets.MY_SECRET }} |
| Upload artifact | uses: actions/upload-artifact@v4, with: name: x, path: ./dist |
| Download artifact | uses: actions/download-artifact@v4, with: name: x |
| Run in subdirectory | working-directory: api |