Angular CI with GitHub Actions: setup-node, npm ci, Lint, Vitest, Build Budgets and Playwright
In an Angular 22 CI, read the Node version from devEngines in package.json with setup-node@v7, install from the lock file with npm ci, run ng lint, Vitest and ng build with budgets, and run Playwright e2e with the API and a PostgreSQL service container. With act output we show why setup-node's automatic npm cache did not turn on in a monorepo, the lock drift error, a Node version mismatch and the effect of the browser cache.
Version note: The examples in this post were tested with act 0.2.97, actionlint 1.7.12, Docker 29.2, Node 24 (24.22.2 locally), npm 11.19, Angular 22.2, angular-eslint 22.6, Vitest 5.1, Playwright 1.65, .NET 10 and PostgreSQL 17. The workflows were run locally with act; they were not run on GitHub.
Guess First
Ada wants a package for date formatting in Bookstore's Angular app. She opens package.json in the editor, adds the line "date-fns": "^4.1.0" under dependencies, runs npm install on her machine, the app starts, and she commits the change. Only package-lock.json does not make it into the commit. What happens when npm ci runs in CI (Continuous Integration)? Does it install the package and pass, pass with a warning, or stop?
$ npm ci
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: date-fns@4.4.0 from lock file
It stops. npm ci is the sibling of npm install designed for CI: it deletes node_modules, installs exactly the tree in the lock file, and refuses to install if package.json and the lock file disagree. It is the npm counterpart of NuGet's --locked-mode from the previous post. In this post we build web-ci.yml for bookstore-web: the Node version, npm ci and caching, ESLint, Vitest, build budgets, and Playwright e2e tests that bring the API (Application Programming Interface) up together with PostgreSQL.
The Node Version: setup-node@v7, node-version-file and devEngines
The Node versions Angular 22 supports are written in the engines field of @angular/cli: ^22.22.3 || ^24.15.0 || >=26.0.0. With a Node that does not match, the CLI (Command Line Interface) does not even start. Trying it with an old Node 22 installation on this machine:
$ node node_modules/@angular/cli/bin/ng.js version
Node.js version v22.2.0 detected.
The Angular CLI requires a minimum Node.js version of v22.22.3 or v24.15.0 or v26.0.0.
Please update your Node.js version or visit https://nodejs.org/ for additional instructions.
Instead of writing the Node version into the workflow as node-version: 24, we put it into the project itself. We added a devEngines section to web/package.json:
"packageManager": "npm@11.19.1",
"devEngines": {
"runtime": {
"name": "node",
"version": "^24.15.0"
}
}
According to the actions/setup-node@v7 documentation, when you pass a package.json to node-version-file, it looks at volta.node, devEngines.runtime (the entry named node) and engines.node, in that order. That way the developer's editor, npm and CI all read from the same place. An .nvmrc or .node-version file does the same job; what matters is that the version lives in a single file.
lint-test-build:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version-file: web/package.json
- run: npm ci
- run: npm run lint
- run: npm test -- --watch=false
- run: npm run build
- uses: actions/upload-artifact@v7
with:
name: web-dist
path: web/dist/bookstore-web/browser/
retention-days: 7
defaults.run.working-directory: web runs all run: steps in the web/ folder; but this setting does not apply to uses: steps. That is why node-version-file and the artifact path are written relative to the repository root.
npm ci and the npm Cache: Why Didn't the Automatic Cache Turn On in the Monorepo?
In the first version of the workflow we wrote nothing for caching. setup-node's README says that since v6 the npm cache turns on automatically if the packageManager or devEngines.packageManager field in package.json points to npm, and the Angular CLI already writes the line "packageManager": "npm@11.19.1" into new projects. But there was not a single cache-related line in the act log:
[web-ci/lint-test-build] ⭐ Run Main actions/setup-node@v7
[web-ci/lint-test-build] | Resolved web/package.json as ^24.15.0
[web-ci/lint-test-build] | Found in cache @ /opt/hostedtoolcache/node/24.22.2/arm64
[web-ci/lint-test-build] | node: v24.22.2
[web-ci/lint-test-build] | npm: 11.19.1
[web-ci/lint-test-build] ✅ Success - Main actions/setup-node@v7 [1.059691708s]
[web-ci/lint-test-build] ⭐ Run Main npm ci
[web-ci/lint-test-build] | added 422 packages, and audited 423 packages in 28s
The Node version was read correctly from web/package.json (^24.15.0 → 24.22.2), but the npm cache did not kick in. The reason is in the source code of setup-node v7.1.0: it looks for the packageManager field only in $GITHUB_WORKSPACE/package.json, that is, at the repository root. Our package.json is under web/; there is none at the root. In a monorepo you have to ask for the cache explicitly:
- uses: actions/setup-node@v7
with:
node-version-file: web/package.json
cache: npm
cache-dependency-path: web/package-lock.json
[web-ci/lint-test-build] | npm cache is not found
[web-ci/lint-test-build] ⚙ ::set-output:: cache-hit=false
[web-ci/lint-test-build] ✅ Success - Main npm ci [37.979782917s]
...
[web-ci/lint-test-build] | Cache Size: ~81 MB (85241513 B)
[web-ci/lint-test-build] | Cache saved with the key: node-cache-Linux-arm64-npm-ae784366ea1f37547f023535ee13d80784f121cd646ec659bcc63f8118c01ad2
...
[web-ci/e2e ] | Cache restored from key: node-cache-linux-arm64-npm-ae784366ea1f37547f023535ee13d80784f121cd646ec659bcc63f8118c01ad2
[web-ci/e2e ] ⚙ ::set-output:: cache-hit=true
[web-ci/e2e ] ✅ Success - Main npm ci [7.835933041s]
There was no cache in the first job and npm ci took 38 seconds; an 81 MB cache was saved at the end of the job. The e2e job in the same run restored it and npm ci dropped to 7.8 seconds.
What is cached is not node_modules but npm's global cache folder; npm ci reinstalls node_modules every time but takes the packages from the cache instead of the internet. The key is derived from the hash of the lock file. The README has a warning: in workflows with elevated privileges or access to sensitive information, it recommends turning automatic caching off with package-manager-cache: false if the cache is not really needed. We will open up cache poisoning in the security post; our CI workflow runs with contents: read, so we left it on here.
Lint: ng lint and angular-eslint
The Angular CLI does not install a linter in new projects; we added one with ng add angular-eslint. The command saw that the workspace is Angular 22, installed angular-eslint 22, created an eslint.config.js and added the lint target to angular.json. A change that breaks a rule, for example using constructor injection in a service:
$ npx ng lint
…/src/app/book.service.ts
15:15 error Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject @angular-eslint/prefer-inject
✖ 1 problem (1 error, 0 warnings)
Lint errors found in the listed files.
We run lint before tests because it takes seconds and catches most simple mistakes without preparing a test environment. Your team picks the rule set; what matters is that CI runs exactly the command that runs locally (npm run lint).
Unit Tests: ng test, Vitest and Watch Mode
Since Angular 21, the default runner of ng test in new projects is Vitest; instead of opening a browser with Karma, the tests run inside jsdom. In our project too, the test target in angular.json uses the @angular/build:unit-test builder and runner defaults to vitest. The important question for CI is watch mode. The builder's schema describes watch like this: true by default in TTY (TeleTYpewriter, an interactive terminal) environments, false otherwise. The terminal on a GitHub runner is not interactive, so by default it runs once; we still wrote --watch=false explicitly, because in an environment that allocates a TTY (some self-hosted runner setups, docker run -t) the same command turns into a job that never ends.
[web-ci/lint-test-build] ⭐ Run Main npm test -- --watch=false
[web-ci/lint-test-build] | > ng test --watch=false
[web-ci/lint-test-build] | RUN v5.1.0 …/web
[web-ci/lint-test-build] | ✓ bookstore-web src/app/app.spec.ts (2 tests) 33ms
[web-ci/lint-test-build] | Test Files 1 passed (1)
[web-ci/lint-test-build] | Tests 2 passed (2)
[web-ci/lint-test-build] ✅ Success - Main npm test -- --watch=false [1.822684792s]
[web-ci/lint-test-build] ⚙ Summary - ## Vitest Test Report
### Summary
- **Test Files**: ✅ **1 pass** · 1 total
- **Test Results**: ✅ **2 passes** · 2 total
The last lines are interesting: we did not write this job summary. Vitest noticed that it was running in GitHub Actions (the GITHUB_ACTIONS variable; act sets it to true as well) and added its own report to GITHUB_STEP_SUMMARY. It is the same mechanism we set up by hand for the .NET tests in the third post.
The unit tests answer the API call with a fake response through HttpTestingController; they do not need the real API. The test that talks to the real API lives in the e2e layer.
Build and Budgets
In the production configuration, ng build checks the budgets in angular.json: a warning at 500 kB and an error at 1 MB for the initial bundle. We explained why these limits exist in the Angular series post "Angular Build and Deploy". What matters for CI: exceeding a budget is a build error, so it turns the pull request red. To see the effect we temporarily lowered the error limit to 120 kB:
$ npx ng build
Application bundle generation failed. [1.349 seconds] - 2026-11-08T04:10:42.877Z
▲ [WARNING] bundle initial exceeded maximum budget. Budget 100.00 kB was not met by 43.94 kB with a total of 143.94 kB.
✘ [ERROR] bundle initial exceeded maximum budget. Budget 120.00 kB was not met by 23.94 kB with a total of 143.94 kB.
The real value of using budgets as a gate in CI is stopping growth from piling up unnoticed. If a pull request grows the initial bundle by 40 kB, discussing it in review is cheaper than searching backwards three months later for "why does the app open slowly". The build output is uploaded as an artifact named web-dist; in the next post we move that artifact into the API's wwwroot.
e2e with Playwright: Together with the API and PostgreSQL
Unit tests test the component in isolation; the e2e test opens the real app in a browser and verifies the path that runs from the real API to the real database. Playwright's webServer setting can bring up several processes and wait for them to be ready before the tests start:
import { defineConfig, devices } from '@playwright/test';
const isCI = !!process.env['CI'];
export default defineConfig({
testDir: './e2e',
forbidOnly: isCI,
retries: isCI ? 1 : 0,
reporter: isCI ? [['github'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: [
{
command: 'dotnet run --project ../api/src/Bookstore.Api --urls http://localhost:5080',
url: 'http://localhost:5080/health',
reuseExistingServer: !isCI,
timeout: 180_000,
},
{
command: 'npm run start -- --port 4200',
url: 'http://localhost:4200',
reuseExistingServer: !isCI,
timeout: 180_000,
},
],
});
import { expect, test } from '@playwright/test';
test('lists the catalog from the API', async ({ page }) => {
await page.goto('/');
const books = page.getByTestId('book');
await expect(books).toHaveCount(3);
await expect(books.filter({ hasText: 'Clean Architecture' })).toContainText('Sold out');
});
The first server starts the API on 5080 with dotnet run and waits until /health returns 200; the second starts the Angular dev server, and thanks to proxy.conf.json the /api requests go to 5080. The database the API needs is the service container in the workflow. forbidOnly treats an accidentally committed test.only as an error in CI; the retries: 1 and trace: 'on-first-retry' pair records a trace file on the second attempt of a test that failed on the first. The github reporter shows failures as annotations on GitHub.
e2e:
needs: lint-test-build
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: bookstore
POSTGRES_USER: bookstore
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 18232:5432
options: >-
--health-cmd "pg_isready -U bookstore -d bookstore"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
ConnectionStrings__Bookstore: Host=localhost;Port=18232;Database=bookstore;Username=bookstore
Database__EnsureCreated: 'true'
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- uses: actions/setup-node@v7
with:
node-version-file: web/package.json
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('web/package-lock.json') }}
- run: npx playwright install --with-deps chromium
- run: npm run e2e
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: playwright-report
path: |
web/playwright-report/
web/test-results/
retention-days: 14
Playwright's browsers do not come with the npm package; they are downloaded separately with npx playwright install. We cache them under ~/.cache/ms-playwright with actions/cache@v6; the key contains the hash of the lock file, because the browser version changes when the Playwright version does. --with-deps also installs the system packages the browser needs; that part is not cached and is installed again with apt in every job. The report and traces are uploaded as artifacts in every case with !cancelled(): when e2e breaks, the first place to look is the HTML (HyperText Markup Language) report and the trace viewer.
[web-ci/e2e ] container health of 87773c5bb9f83c0d3a2619c12829f14cc13998e7f7bfdcc9c8049a08a57a6625 (postgres:17-alpine) is healthy
...
[web-ci/e2e ] | Cache not found for input keys: playwright-Linux-ARM64-ae784366ea1f37547f023535ee13d80784f121cd646ec659bcc63f8118c01ad2
[web-ci/e2e ] | Chrome Headless Shell 158.0.8127.18 (playwright chromium-headless-shell v1254) downloaded to /root/.cache/ms-playwright/chromium_headless_shell-1254
[web-ci/e2e ] ✅ Success - Main npx playwright install --with-deps chromium [32.616624375s]
[web-ci/e2e ] ⭐ Run Main npm run e2e
[web-ci/e2e ] | Running 1 test using 1 worker
[web-ci/e2e ] | 1 passed (1.9m)
[web-ci/e2e ] ❓ ::notice title=🎭 Playwright Run Summary:: 1 passed (1.9m)
[web-ci/e2e ] | Artifact playwright-report has been successfully uploaded! Final size is 194377 bytes. Artifact ID is 61284767
[web-ci/e2e ] ⭐ Run Post actions/cache@v6
[web-ci/e2e ] | Cache Size: ~292 MB (306192637 B)
[web-ci/e2e ] | Cache saved with key: playwright-Linux-ARM64-ae784366ea1f37547f023535ee13d80784f121cd646ec659bcc63f8118c01ad2
On this first run there was no browser cache; Playwright downloaded Chrome for Testing 158 and the installation took 33 seconds. The test itself shows 1.9 minutes because that time includes starting the webServers: the API's first dotnet run built the project from scratch, and the Angular dev server did its first build too. The 292 MB browser cache was saved at the end of the job. We ran the artifact steps in act with upload-artifact@v4; act 0.2.97's artifact server does not support @v7 (details in the previous post).
Production Notes
e2e tests are expensive and fragile; write few of them, for the critical flows. In Bookstore, "the catalog opens and stock status shows correctly" is a single test; when the cart and checkout flows arrive, each becomes one test. If you try to cover every UI (User Interface) change with e2e, pull requests wait for minutes and tests break randomly and often.
We saw the effect of caching by running the same workflow a second time: the Playwright cache was restored (Cache restored from key: playwright-linux-arm64-…), npx playwright install --with-deps dropped from 33 seconds to 15 (the system packages were still installed with apt), the test finished with 1 passed (10.3s), and the whole workflow went from 4.5 minutes to 2. Most of the 1.9 minutes in the first run were the machine's load at the time and cold builds; on a GitHub-hosted runner the numbers will differ, but the ratio moves in the same direction.
Hiding a flaky (sometimes passing, sometimes failing) e2e test with retries is tempting, but Playwright's report lists tests marked "flaky" separately. Follow that list; if the same test shows up there every week, the problem is in the test or the app, not in the retry.
Common Pitfalls
Lock file drift. Editing package.json by hand and not committing the lock file stops npm ci. After npm install, commit both files together.
A Node major version mismatch. A fixed value like node-version: 20 in the workflow means the CLI won't even start once Angular is upgraded. Read the version from package.json.
A test job stuck in watch mode. In an environment with a TTY, ng test or plain vitest enters watch mode and never ends; the job then waits until the default 360-minute timeout. Use --watch=false and timeout-minutes together.
Assuming working-directory applies to uses: steps. defaults.run only affects run: steps; paths in action inputs are written relative to the repository root.
Relying on the automatic npm cache in a monorepo. setup-node looks for the packageManager field only in the package.json at the repository root; if the app is in a subfolder, write cache: npm and cache-dependency-path explicitly.
Running e2e against a database without a health check. When we first ran the Playwright test locally while preparing this post, the PostgreSQL container was not ready yet and the API died during EnsureCreated: Process from config.webServer was not able to start. Exit code: 134. The second attempt passed. In CI the service container's health check prevents the same race.
A Small Experiment
★☆☆ Lower the anyComponentStyle budget in angular.json to 1 kB, add a long rule to a component's CSS (Cascading Style Sheets), and see what ng build says.
★★☆ Add Vitest coverage to web-ci.yml (ng test --coverage) and write the coverage summary to the job summary.
★★★ Add a "book detail" scenario to e2e: test what the app shows for a book id that does not exist in the API. Break the test on purpose, run it with act, and open the trace in the playwright-report artifact with npx playwright show-trace.
Right now we have two separate workflows: api-ci.yml and web-ci.yml, each with its own path filter. In the next post we move on to the monorepo's real questions: separate workflows or one, how required checks get along with path filters, how Angular's dist is carried into the .NET publish, and how a single release package is produced on a tag push.