webhook: skip npm ci when dependency files are unchanged

Deploys ran `npm ci` unconditionally, which wipes node_modules and
rebuilds native modules (better-sqlite3) on every push — ~20 min even
for a frontend-only change with no dependency changes.

Gate the clean install on package.json / package-lock.json actually
changing between the pre-pull HEAD and the new HEAD. As a safety net,
still install whenever the build's required modules (@vitejs/plugin-react,
@rollup/rollup-linux-x64-*) are missing from node_modules, so a skipped
install can never leave an incomplete tree. Frontend-only deploys now
skip straight to the build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Liam
2026-06-29 13:56:35 +00:00
parent 2ccb17f608
commit 0b4e6b0103
+62 -14
View File
@@ -323,7 +323,63 @@ function runCapture(command, args, options = {}) {
})
}
async function ensureBuildDependencies() {
// Files that, when changed, require a fresh `npm ci`. package.json is included
// because npm ci refuses to run against a package.json/lockfile that are out of
// sync, so a change to either should trigger a reinstall.
const DEPENDENCY_FILES = ['package.json', 'package-lock.json']
// Resolve the modules the build relies on. Returns the first missing module's
// name, or null if all are present. Used both to validate after an install and
// to decide whether a skipped install left an incomplete node_modules.
function missingBuildDependency() {
try {
require.resolve('@vitejs/plugin-react')
} catch {
return '@vitejs/plugin-react'
}
if (process.platform === 'linux' && process.arch === 'x64') {
const libc = process.report?.getReport?.().header?.glibcVersionRuntime ? 'gnu' : 'musl'
const rollup = `@rollup/rollup-linux-x64-${libc}`
try {
require.resolve(rollup)
} catch {
return rollup
}
}
return null
}
// True when any dependency file changed between previousHead and the current
// HEAD. Defaults to true (reinstall) whenever we can't determine the range, so
// an uncertain state never skips a needed install.
async function dependencyFilesChanged(previousHead) {
if (!validGitSha(previousHead)) return true
try {
const changed = await runCapture('git', [
'diff',
'--name-only',
`${previousHead}..HEAD`,
'--',
...DEPENDENCY_FILES,
])
return changed.trim().length > 0
} catch {
return true
}
}
async function ensureBuildDependencies(previousHead) {
const depsChanged = await dependencyFilesChanged(previousHead)
// Skip the clean reinstall (which wipes node_modules and rebuilds native
// modules like better-sqlite3) only when no dependency files changed AND the
// existing node_modules already has everything the build needs.
const canSkip = !depsChanged && missingBuildDependency() === null
if (canSkip) {
console.log('deploy step skipped: npm ci (no package.json/package-lock.json changes)')
} else {
await run('npm', ['ci'], {
env: {
NODE_ENV: 'development',
@@ -334,18 +390,9 @@ async function ensureBuildDependencies() {
},
})
try {
require.resolve('@vitejs/plugin-react')
} catch {
throw new Error('@vitejs/plugin-react is missing after npm install; dev dependencies were not installed')
}
if (process.platform === 'linux' && process.arch === 'x64') {
const libc = process.report?.getReport?.().header?.glibcVersionRuntime ? 'gnu' : 'musl'
try {
require.resolve(`@rollup/rollup-linux-x64-${libc}`)
} catch {
throw new Error(`@rollup/rollup-linux-x64-${libc} is missing after npm install; optional dependencies were not installed`)
const missing = missingBuildDependency()
if (missing) {
throw new Error(`${missing} is missing after npm install; dependencies were not installed`)
}
}
}
@@ -596,9 +643,10 @@ async function deploy(push) {
try {
await notifyDeployStarted(push)
const previousHead = await runCapture('git', ['rev-parse', 'HEAD']).catch(() => null)
await run('git', ['pull', '--ff-only'])
diff = await deployDiff(push)
await ensureBuildDependencies()
await ensureBuildDependencies(previousHead)
await run('npm', ['run', 'build', '--', '--outDir', '../dist-next'])
validateBuiltDist()
await run('cargo', ['build', '--manifest-path', 'backend/Cargo.toml', '--release'])