Skip to content

Node frontend (Next.js, Vite, etc.)

The gocdnext/node plugin ships a corepack + pnpm shim that resolves the package manager from packageManager: in package.json. Tests run, type-check is its own gate, the production bundle is assembled, and the pnpm store survives across runs so a warm install drops to seconds.

Layout assumed

repo/
├── package.json # with `packageManager: "pnpm@9.x.x"`
├── pnpm-lock.yaml
├── tsconfig.json
└── src/...

This recipe is what powers gocdnext’s own dashboard build at .gocdnext/ci-web.yaml. Same shape works for Vite, Remix, plain TypeScript libraries.

The pipeline

.gocdnext/ci-web.yaml
name: ci-web
when:
event: [push, pull_request]
stages: [install, lint, test, build]
jobs:
deps:
stage: install
uses: ghcr.io/klinux/gocdnext-plugin-node@v2
with:
working-dir: web
# install: true (default) + command: "" (default) →
# install-only job. Plugin runs `pnpm install --frozen-lockfile`
# automatically.
cache:
- key: pnpm-store-{{ hash "web/pnpm-lock.yaml" }}
paths: [web/.pnpm-store]
artifacts:
paths: ["web/node_modules/"]
typecheck:
stage: lint
uses: ghcr.io/klinux/gocdnext-plugin-node@v2
needs: [deps]
needs_artifacts:
- from_job: deps
paths: ["web/node_modules/"]
with:
working-dir: web
install: "false" # reuse the artifact, skip resolve
command: pnpm exec tsc --noEmit
unit:
stage: test
uses: ghcr.io/klinux/gocdnext-plugin-node@v2
needs: [deps]
needs_artifacts:
- from_job: deps
paths: ["web/node_modules/"]
with:
working-dir: web
install: "false"
command: pnpm test --run
test_reports:
- "web/junit.xml"
bundle:
stage: build
uses: ghcr.io/klinux/gocdnext-plugin-node@v2
needs: [typecheck, unit]
needs_artifacts:
- from_job: deps
paths: ["web/node_modules/"]
variables:
# Build-time placeholder — real value lives at runtime.
NEXT_PUBLIC_API_URL: http://localhost:8153
with:
working-dir: web
install: "false"
command: pnpm build
artifacts:
paths:
- "web/.next/standalone/"
- "web/.next/static/"

What’s worth highlighting:

needs_artifacts: is what passes node_modules/ between jobs

Each job runs in a fresh container — the deps job’s working tree disappears at the end. needs: only orders jobs (it doesn’t pass files); needs_artifacts: pulls a tar of the listed paths from the upstream job’s artefact backend back into the downstream job’s workspace.

This pattern (install once, reuse) cuts a 4-job pipeline from “install × 4” to “install × 1 + restore × 3”. On a typical Next.js project that’s ~30 seconds saved per warm run.

install: true (default) + frozen: true (default)

The v2 plugin handles dependency install automatically. With both defaults on, the deps job’s only with: line is working-dir: — the plugin runs pnpm install --frozen-lockfile itself. Without frozen: true, pnpm would UPDATE the lockfile if it disagreed with the manifest; CI should never silently rewrite the lockfile, so the flag turns a drift into a failed install. Set frozen: false explicitly only when you intentionally want lockfile auto-fix (rare in CI).

install: false on downstream jobs

Lint / test / build jobs restore node_modules/ via needs_artifacts: from the upstream install. They DON’T re-run the install — install: false skips it. Without that flag the plugin would run a fresh pnpm install --frozen-lockfile, re-resolving the lockfile and undoing the artifact restore.

pnpm-store cache lives in the workspace

The plugin’s entrypoint runs pnpm config set store-dir /workspace/.pnpm-store so the platform’s cache: block can tar the content-addressable store. Default is ~/.local/share/pnpm/ which the agent can’t see.

packageManager: in package.json pins the pnpm version — the plugin’s corepack shim resolves it at runtime so two projects with different pnpm versions can run on the same agent without conflict.

pnpm exec tsc --noEmit

tsc --noEmit is the type-only check. Running it as a separate job from unit lets the run-detail UI show a clear “type errors” vs “test failures” split — easier to triage than a single combined job that fails for “some reason”.

Scoping triggers to a sub-tree

Path-based filtering inside when: isn’t supported by the parser today — it accepts event: and branch: only. The idiomatic split for a polyrepo / monorepo is one pipeline per sub-tree (ci-server.yaml, ci-web.yaml, …) and let each pipeline’s implicit project material trigger all of them on every push. The heavy install/build work is then bounded by hash-keyed caches, so unaffected pipelines complete in seconds.

Variations

Vite + Vitest with coverage

unit:
stage: test
uses: ghcr.io/klinux/gocdnext-plugin-node@v2
needs: [deps]
needs_artifacts:
- from_job: deps
paths: ["node_modules/"]
with:
install: "false"
command: pnpm exec vitest run --coverage
test_reports:
- "junit.xml"
artifacts:
paths: ["coverage/"]
upload-coverage:
stage: test
uses: ghcr.io/klinux/gocdnext-plugin-codecov@v1
needs: [unit]
needs_artifacts:
- from_job: unit
paths: ["coverage/lcov.info"]
with:
file: coverage/lcov.info
flags: vite
secrets:
- CODECOV_TOKEN

Lighthouse CI for performance budgets

Services are declared at pipeline level (so all jobs in the run can reach them). The lighthouse job hits http://app:3000/app is the service name: as DNS alias.

services:
- name: app
image: nginx:alpine
# serve the dist/ on port 80 — adapt to your real image
jobs:
lighthouse:
stage: test
uses: ghcr.io/klinux/gocdnext-plugin-lighthouse-ci@v1
needs: [bundle]
needs_artifacts:
- from_job: bundle
paths: ["dist/"]
with:
urls: |
http://app/
http://app/pricing
number-of-runs: "3"

Playwright e2e

e2e:
stage: test
uses: ghcr.io/klinux/gocdnext-plugin-playwright@v1
needs: [bundle]
needs_artifacts:
- from_job: bundle
paths: ["dist/"]
with:
command: test --reporter=junit
test_reports:
- "playwright-junit.xml"
artifacts:
paths: ["playwright-report/"]

The Playwright plugin image ships Chromium, Firefox, and WebKit — tests of all three browsers in one job.

Monorepo — one pipeline per package

Path filtering in when: isn’t wired. For a Turborepo/Nx layout, land one pipeline file per package (.gocdnext/ci-web-app.yaml, .gocdnext/ci-api-gateway.yaml, …) so each pipeline’s failures are scoped and its runs name themselves. Backend-only PRs still trigger every pipeline, but unaffected ones hit warm caches and finish in seconds.

Common pitfalls

  • packageManager: mismatch with the pnpm in CI: corepack resolves at runtime, so the version in package.json is what runs. Update it in the same PR that bumps the lockfile or expect drift.
  • pnpm-store cache size: real apps land at 1-2 GB. Bump caches.projectQuotaBytes in Helm if you’re on the default 100 GiB cluster cap with many projects.
  • exec tsc vs run typecheck: prefer exec tsc --noEmit unless package.json has a custom typecheck script — exec bypasses the script lookup and goes straight to the binary.
  • NODE_ENV=production during pnpm install prunes devDependencies — fine for runtime but breaks pnpm test later. Leave NODE_ENV alone in install, set it on the build/bundle job only.