From 0b4e6b01034da75b1bbcdbd4dc6dfc9d29c7924e Mon Sep 17 00:00:00 2001 From: Liam Date: Mon, 29 Jun 2026 13:56:35 +0000 Subject: [PATCH] webhook: skip npm ci when dependency files are unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- webhook.cjs | 76 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/webhook.cjs b/webhook.cjs index ded0fb1..050a856 100644 --- a/webhook.cjs +++ b/webhook.cjs @@ -323,29 +323,76 @@ function runCapture(command, args, options = {}) { }) } -async function ensureBuildDependencies() { - await run('npm', ['ci'], { - env: { - NODE_ENV: 'development', - npm_config_omit: '', - npm_config_production: 'false', - npm_config_fund: 'false', - npm_config_audit: 'false', - }, - }) +// 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 { - throw new Error('@vitejs/plugin-react is missing after npm install; dev dependencies were not installed') + 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/rollup-linux-x64-${libc}`) + require.resolve(rollup) } catch { - throw new Error(`@rollup/rollup-linux-x64-${libc} is missing after npm install; optional dependencies were not installed`) + 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', + npm_config_omit: '', + npm_config_production: 'false', + npm_config_fund: 'false', + npm_config_audit: 'false', + }, + }) + + 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'])