From e3c71c3de3b75e88a459eda266403abbc7a8d7c2 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 17 Apr 2026 16:36:42 -0400 Subject: [PATCH] chore(build): hash inputs in smart-build cache check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends `needsBuild()` in the smart-build script so it rebuilds when the bundler configs, build scripts, source, lockfile, or Node version change — not only when the dist output is missing. How it works: - Each package entry declares a list of input globs. - We SHA-256 the file paths + contents into a "build signature." - The signature is written alongside the output (e.g. `packages/cli/dist/index.js.build-signature`) after a successful build. - The next smart build re-hashes inputs; if the hash differs, we rebuild. Force (`--force`) still bypasses everything. Signature sidecars are gitignored. --- .gitignore | 1 + scripts/build.mts | 88 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 082c46aa5..78c28d4c0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ yarn-error.log* # Build outputs and artifacts # ============================================================================ **/.build-checkpoints +**/*.build-signature **/.cache/ /.rollup.cache **/.type-coverage/ diff --git a/scripts/build.mts b/scripts/build.mts index 77b191db7..e383b3630 100755 --- a/scripts/build.mts +++ b/scripts/build.mts @@ -18,11 +18,14 @@ * pnpm run build --help # Show this help */ -import { existsSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' +import fg from 'fast-glob' + import colors from 'yoctocolors-cjs' import { WIN32 } from '@socketsecurity/lib/constants/platform' @@ -54,6 +57,12 @@ interface BuildPackageConfig { name: string filter: string outputCheck: string + /** + * Glob patterns (repo-relative) whose file contents contribute to the build + * signature. A change to any matching file invalidates the cache and forces + * a rebuild. + */ + inputs: string[] } interface BuildResult { @@ -87,9 +96,63 @@ const BUILD_PACKAGES: BuildPackageConfig[] = [ name: 'CLI Package', filter: '@socketsecurity/cli', outputCheck: 'packages/cli/dist/index.js', + inputs: [ + 'packages/cli/.config/**/*.{mts,ts,json}', + 'packages/cli/scripts/**/*.{mts,ts}', + 'packages/cli/src/**/*.{mts,ts,cts,json}', + 'packages/cli/package.json', + 'packages/cli/tsconfig.json', + 'packages/build-infra/lib/**/*.{mts,ts}', + 'packages/build-infra/package.json', + 'pnpm-lock.yaml', + '.node-version', + ], }, ] +/** + * Compute a SHA-256 signature over the contents of files matched by the + * package's input globs. Files are sorted to keep the hash deterministic. + */ +function computeBuildSignature(pkg: BuildPackageConfig): string { + const files = fg.sync(pkg.inputs, { + cwd: rootDir, + onlyFiles: true, + dot: true, + absolute: true, + }) + files.sort() + + const hash = createHash('sha256') + for (const file of files) { + const relative = path.relative(rootDir, file) + hash.update(relative) + hash.update('\0') + hash.update(readFileSync(file)) + hash.update('\0') + } + return hash.digest('hex') +} + +/** + * Path to the sidecar signature file written alongside the build output. + */ +function signaturePath(pkg: BuildPackageConfig): string { + return path.join(rootDir, `${pkg.outputCheck}.build-signature`) +} + +function readSignature(pkg: BuildPackageConfig): string | null { + const file = signaturePath(pkg) + if (!existsSync(file)) { + return null + } + return readFileSync(file, 'utf8').trim() +} + +function writeSignature(pkg: BuildPackageConfig, signature: string): void { + writeFileSync(signaturePath(pkg), `${signature}\n`, 'utf8') +} + /** * Parse command line arguments. */ @@ -210,6 +273,12 @@ function showHelp(): void { /** * Check if a package needs to be built. * Returns true if build is needed, false if can skip. + * + * Rebuild triggers: + * 1. --force + * 2. Missing build output + * 3. Missing signature sidecar + * 4. Current input signature differs from the recorded one */ function needsBuild(pkg: BuildPackageConfig, force: boolean): boolean { if (force) { @@ -221,8 +290,12 @@ function needsBuild(pkg: BuildPackageConfig, force: boolean): boolean { return true } - // Output exists, can skip. - return false + const stored = readSignature(pkg) + if (!stored) { + return true + } + + return computeBuildSignature(pkg) !== stored } /** @@ -263,6 +336,15 @@ async function buildPackage( logger.log( `${colors.green('✓')} ${pkg.name}: ${colors.green('built')} (${duration}s)`, ) + + try { + writeSignature(pkg, computeBuildSignature(pkg)) + } catch (e) { + logger.warn( + `Could not write build signature for ${pkg.name}: ${e instanceof Error ? e.message : String(e)}`, + ) + } + return { success: true, skipped: false } }