CI/CD Pipelines
A CI/CD pipeline turns the manual ritual of “lint, test, build, deploy” into an automated workflow that runs on every push and pull request. For an Angular app this means catching broken code before it merges, producing a reproducible production bundle, and shipping it to your host without a human running commands locally. GitHub Actions is the most common choice because it lives next to your code, but the same stages apply to GitLab CI, CircleCI, or Azure Pipelines.
The stages of an Angular pipeline
A healthy pipeline runs a sequence of gates, each fast enough to give quick feedback and strict enough to block bad code. The typical order is: install dependencies, lint, run unit tests, build for production, then deploy. The first three stages run on every pull request; deployment usually runs only on the default branch after a merge.
| Stage | Command | Purpose |
|---|---|---|
| Install | npm ci | Deterministic dependency install |
| Lint | ng lint | Enforce code style and rules |
| Test | ng test --watch=false --browsers=ChromeHeadless | Run unit specs headless |
| Build | ng build --configuration production | Produce the optimized bundle |
| Deploy | host-specific | Publish the dist output |
Use
npm cirather thannpm installin CI. It installs exactly what is inpackage-lock.json, fails if the lock file is out of sync, and is significantly faster on a clean machine.
A complete GitHub Actions workflow
Create .github/workflows/ci.yml. Workflows live in .github/workflows/ and are written in YAML. This one runs the quality gates on every pull request and on pushes to main.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Unit tests
run: npm test -- --watch=false --browsers=ChromeHeadless
- name: Build
run: npm run build -- --configuration production
The cache: npm option on setup-node caches the npm download directory keyed on your lock file, so subsequent runs skip re-downloading unchanged packages. ubuntu-latest ships with a headless Chrome that Karma can drive, so no extra browser install is needed.
Output:
✔ Install dependencies 12s
✔ Lint 8s
✔ Unit tests 24s
Chrome Headless: Executed 47 of 47 SUCCESS
✔ Build 19s
Output location: dist/my-app/browser
Wiring up the scripts
The workflow above calls npm run lint, npm test, and npm run build. Make sure these exist in package.json so the pipeline and local development share the same commands.
{
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint"
}
}
If you have not added linting yet, run ng add @angular-eslint/schematics once locally; it installs ESLint and wires the lint target into angular.json.
Running tests headlessly
By default ng test opens a browser and watches files — neither works in CI. Pass flags to run once in headless Chrome, or define a CI-specific configuration in angular.json under the test builder so the pipeline command stays short.
// karma.conf.js (excerpt) — a CI-friendly headless launcher
module.exports = function (config) {
config.set({
browsers: ['ChromeHeadlessCI'],
customLaunchers: {
ChromeHeadlessCI: {
base: 'ChromeHeadless',
flags: ['--no-sandbox', '--disable-gpu'],
},
},
});
};
The --no-sandbox flag is required because CI runners execute as root, where Chrome’s sandbox refuses to start.
Adding a deploy job
Deployment should depend on the build job passing and run only on main. Use needs to chain jobs and an if condition to gate the branch. This example deploys static output to Netlify, but the pattern — gate, build, upload — is identical for any host.
deploy:
needs: build
if: github.ref == 'refs/heads/main'
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 -- --configuration production
- name: Deploy to Netlify
run: npx netlify-cli deploy --prod --dir=dist/my-app/browser
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
Secrets like the auth token are stored in the repository’s Settings → Secrets and variables → Actions and injected as environment variables — never commit them to the repo.
The
deployjob rebuilds rather than reusing thebuildjob’s output, because each job runs on a fresh machine. For larger apps, upload thedistfolder withactions/upload-artifactinbuildand download it indeployto avoid building twice.
Caching to speed things up
Beyond the npm cache, the Angular CLI keeps its own build cache in .angular/cache. Persisting it across runs makes incremental builds faster.
- name: Cache Angular build
uses: actions/cache@v4
with:
path: .angular/cache
key: angular-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
restore-keys: angular-${{ hashFiles('package-lock.json') }}-
The restore-keys fallback lets a new commit reuse the previous commit’s cache even when the SHA differs, which is what makes the cache actually pay off.
Best Practices
- Run lint, test, and build on every pull request so problems are caught before merge, not after.
- Use
npm cifor deterministic, lock-file-driven installs in every CI job. - Run unit tests in headless Chrome with
--no-sandboxso they pass on root-based runners. - Gate the deploy job behind
needs:and anif: github.ref == 'refs/heads/main'check so only reviewed, merged code ships. - Store all credentials as encrypted repository secrets and reference them through
env, never in committed files. - Cache both the npm directory and
.angular/cacheto keep pipeline runs fast as the app grows. - Pin action versions (e.g.
actions/checkout@v4) so a third-party update cannot silently change your pipeline.