Building in CI
A pipeline that installs, type-checks, lints, tests and builds, with dependency caching and a job that fails on a type error.
npm ci && npm run type-check && npx eslint . && npm run test:unit -- --run && npm run build-onlyA build on your machine proves it works on your machine. A build in continuous integration proves it works from a clean checkout, with the dependency versions the lockfile pins, on every push. That is the whole value: the failure arrives before the deploy, not after it.
The stages
Install with npm ci, not npm install. It installs exactly what package-lock.json says and fails if the lockfile and package.json disagree. Then type-check with vue-tsc --build, lint, run unit tests, and build. Each step is a separate command so the log shows which one broke.
Failing on a type error
Every step fails the job when its exit code is non-zero, and that is all it takes. The trap is in the scripts themselves. npm run build in the scaffold already runs type-check alongside build-only; if the pipeline also runs type-check as its own step, use build-only for the bundle so the work is not done twice. Similarly, vitest with no arguments stays in watch mode and would hang the job, so pass --run.
Caching
Cache the package manager download directory, keyed on the lockfile, not node_modules. actions/setup-node does this with cache: npm. Never cache dist: the build output is what you are trying to verify.
Example
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
items: readonly string[]
max?: number
}
// Vue 3.5: destructuring props with a default is stable and reactive.
// The longhand is withDefaults(defineProps<Props>(), { max: 5 }).
const { items, max = 5 } = defineProps<Props>()
const visible = computed<readonly string[]>(() => items.slice(0, max))
const hiddenCount = computed<number>(() => Math.max(items.length - max, 0))
</script>
<template>
<ul class="tag-list">
<!-- :key is required, and a stable value, not the index -->
<li v-for="item in visible" :key="item">{{ item }}</li>
<li v-if="hiddenCount > 0">and {{ hiddenCount }} more</li>
</ul>
</template>
<style scoped>
.tag-list {
margin: 0;
padding-left: 1rem;
}
</style>When to use it
- Block a pull request whose template passes a prop of the wrong type.
- Produce one verified dist artifact that the deploy job publishes without rebuilding.
- Cut pipeline minutes by caching the npm download directory keyed on the lockfile.
- Reproduce a CI failure locally with a single verify script that runs the same steps in the same order.
More examples
The pipeline
Six commands in the order that fails fastest and cheapest: install, then the checks that take seconds, then the build. cache: npm restores the npm cache keyed on package-lock.json, so a run with unchanged dependencies skips most of the download. Uploading dist gives you the exact artifact a deploy step can publish, rather than rebuilding and hoping it matches.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
# Any non-zero exit here fails the job. vue-tsc exits 1 on a type error.
- run: npm run type-check
# Not the scaffold's lint script: --fix would rewrite and exit 0.
- run: npx eslint .
# --run stops vitest entering watch mode and hanging the runner.
- run: npm run test:unit -- --run
# build-only, because type-check already ran as its own step.
- run: npm run build-only
- uses: actions/upload-artifact@v4
with:
name: dist
path: distScripts a pipeline can call
Two additions to the scaffold. lint:ci is the same linter without --fix, so it reports instead of rewriting. verify chains the whole pipeline with && and is the script to run locally before pushing, because it fails in the same place and the same order as CI. Keep the chain in package.json rather than only in the workflow file, so it is not tied to one CI provider.
{
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"build-only": "vite build",
"preview": "vite preview",
"type-check": "vue-tsc --build",
"test:unit": "vitest",
"lint": "eslint . --fix",
"lint:ci": "eslint .",
"format": "prettier --write src/",
"verify": "npm run type-check && npm run lint:ci && npm run test:unit -- --run && npm run build-only"
}
}Catching the type error before it reaches CI
vue-tsc checks expressions inside templates, not just script blocks, which is why a wrong prop type on a component tag is caught here. The non-zero exit status is the mechanism the CI step relies on; nothing else has to be configured. Run this command locally with the same arguments as the workflow, and a red pipeline stops being a surprise.
$ npm run type-check
> vue-tsc --build
src/views/TagsView.vue:14:16 - error TS2322: Type 'number' is not
assignable to type 'readonly string[]'.
14 <tag-list :items="42" :max="3" />
~~~~~~
Found 1 error.
$ echo $?
2
Discussion