Skip to content

Commit 2d9bec8

Browse files
committed
feat(build): port scripts/build.mts to shared build-pipeline orchestrator
Fifth copy of the shared build-pipeline system (socket-btm + ultrathink + socket-tui + sdxgen all use it). API surface is identical across repos — manifest-of-stages, --force/--clean/--clean-stage/--from-stage/ --cache-key CLI, checkpoint JSONs under build/<mode>/. What landed - packages/build-infra/lib: adds checkpoint-manager, constants, external-tools-schema, platform-mappings, version-helpers, build-pipeline alongside the existing esbuild/platform-targets/ github-releases helpers. Coexists — no touching the pre-existing files. - constants.mts: socket-cli's own checkpoint chain (CLI → SEA → FINALIZED). No wasm verbs — socket-cli consumes pre-built wasm + node binaries from socket-btm; same orchestrator drives a pure JS build, the module name 'build-pipeline' reflects that. - build-pipeline.mts: add `skip?: (ctx) => boolean` dynamic skip predicate. skipInDev stays for fleet parity; skip is for dynamic conditions like socket-cli's SEA stage (only runs on --force/--prod). - scripts/build.mts: replace runSmartBuild's procedural loop with a runPipelineCli call. Existing buildPackage/buildCurrentPlatformSea helpers are wrapped as stage workers; the existing BUILD_PACKAGES signature system still runs inside buildPackage's body, complementing the orchestrator's own cache-hash layer (the two aren't fighting — one works on file-glob inputs, the other on content hashes of sourcePaths + platform metadata). - Dispatch paths unchanged: --platforms / --targets / --target still route to runParallelBuilds / runSequentialBuilds / runTargetedBuild (the orchestrator only replaces the default smart-build path). - Platform pinned to 'universal' via resolvePlatformArch so the cache key stays stable across runner OSes (bundled CLI JS is universal). - Checkpoints at build/<mode>/checkpoints/ matching socket-tui + sdxgen. Bump @socketsecurity/lib catalog pin 5.21 → 5.24. 5.21's /errors subpath shipped CJS without named-export interop, so `import { errorMessage } from '@socketsecurity/lib/errors'` failed under Node's ESM-to-CJS resolver. 5.24 ships the interop (already in use by socket-tui, ultrathink, socket-sdxgen). Also: add @sinclair/typebox to catalog (needed by external-tools-schema.mts). Verified locally: `pnpm build` completes in 16s, CLI Package builds, SEA correctly skipped (skip predicate), FINALIZED writes checkpoint. Cached rerun: 0.0s. --help / --target / --platforms paths unchanged.
1 parent 579638d commit 2d9bec8

11 files changed

Lines changed: 1428 additions & 99 deletions

packages/build-infra/lib/build-pipeline.mts

Lines changed: 482 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/**
2+
* Build checkpoint manager (lean).
3+
*
4+
* Same public API as socket-btm's checkpoint-manager but sized for the
5+
* single-stage wasm builds in this repo (lang/{rust,cpp,go}). Each stage
6+
* writes a JSON marker `{ name }.json` keyed by a content hash of its
7+
* source inputs + platform/arch/mode. If the hash matches next run, the
8+
* stage is skipped.
9+
*
10+
* What this intentionally omits vs socket-btm:
11+
* - Tarball archival (socket-btm archives the built artifact so CI can
12+
* restore it between jobs; lang wasm rebuilds take seconds, not 30 min).
13+
* - Ad-hoc macOS codesign (wasm artifacts don't need it).
14+
* - Cross-process atomic-write ceremony (no concurrent CI jobs racing on
15+
* the same build dir in this repo).
16+
* - restoreCheckpoint (nothing to restore when there's no tarball).
17+
*
18+
* Exports mirror the names build-pipeline consumes, so the orchestrator is
19+
* identical across repos.
20+
*/
21+
22+
import { createHash } from 'node:crypto'
23+
import { existsSync, promises as fs, readFileSync } from 'node:fs'
24+
import path from 'node:path'
25+
import process from 'node:process'
26+
27+
import { errorMessage } from '@socketsecurity/lib/errors'
28+
import { safeDelete, safeMkdir } from '@socketsecurity/lib/fs'
29+
import { getDefaultLogger } from '@socketsecurity/lib/logger'
30+
31+
const logger = getDefaultLogger()
32+
33+
function checkpointDir(buildDir, packageName) {
34+
return packageName
35+
? path.join(buildDir, 'checkpoints', packageName)
36+
: path.join(buildDir, 'checkpoints')
37+
}
38+
39+
function checkpointFile(buildDir, packageName, name) {
40+
return path.join(checkpointDir(buildDir, packageName), `${name}.json`)
41+
}
42+
43+
function hashSourcePaths(sourcePaths) {
44+
const hash = createHash('sha256')
45+
for (const file of [...sourcePaths].sort()) {
46+
hash.update(`${file}:`)
47+
if (existsSync(file)) {
48+
try {
49+
hash.update(readFileSync(file))
50+
} catch (e) {
51+
if (e.code !== 'ENOENT') {
52+
throw e
53+
}
54+
}
55+
}
56+
}
57+
return hash.digest('hex')
58+
}
59+
60+
function platformCacheKey({ buildMode, nodeVersion, platform, arch, libc }) {
61+
const parts = [
62+
buildMode && `mode=${buildMode}`,
63+
nodeVersion && `node=${nodeVersion}`,
64+
platform && `platform=${platform}`,
65+
arch && `arch=${arch}`,
66+
libc && `libc=${libc}`,
67+
].filter(Boolean)
68+
if (!parts.length) {
69+
return ''
70+
}
71+
return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16)
72+
}
73+
74+
function computeCacheHash(sourcePaths, options) {
75+
const sourcesHash = sourcePaths?.length ? hashSourcePaths(sourcePaths) : ''
76+
const platformHash = platformCacheKey(options || {})
77+
if (!sourcesHash && !platformHash) {
78+
return ''
79+
}
80+
return createHash('sha256')
81+
.update(sourcesHash)
82+
.update('|')
83+
.update(platformHash)
84+
.digest('hex')
85+
}
86+
87+
/**
88+
* Does a checkpoint JSON marker exist?
89+
*/
90+
export function hasCheckpoint(buildDir, packageName, name) {
91+
return existsSync(checkpointFile(buildDir, packageName, name))
92+
}
93+
94+
/**
95+
* Read a checkpoint's JSON data, or undefined if it does not exist.
96+
*/
97+
export async function getCheckpointData(buildDir, packageName, name) {
98+
const file = checkpointFile(buildDir, packageName, name)
99+
if (!existsSync(file)) {
100+
return undefined
101+
}
102+
try {
103+
return JSON.parse(await fs.readFile(file, 'utf8'))
104+
} catch (e) {
105+
logger.warn(
106+
`Checkpoint ${name} JSON unreadable (${errorMessage(e)}) — ignoring`,
107+
)
108+
return undefined
109+
}
110+
}
111+
112+
/**
113+
* Run `smokeTest`, then write a checkpoint JSON marker.
114+
*
115+
* @param {string} buildDir
116+
* @param {string} name - Checkpoint name (must be a CHECKPOINTS value).
117+
* @param {() => Promise<void>} smokeTest - Throws if the stage output is invalid.
118+
* @param {object} [options]
119+
* @param {string} [options.packageName]
120+
* @param {string} [options.artifactPath] - Informational; recorded in JSON.
121+
* @param {string} [options.binaryPath] - Informational; recorded in JSON.
122+
* @param {string|number} [options.binarySize] - Informational; recorded in JSON.
123+
* @param {string[]} [options.sourcePaths] - Inputs hashed into the cache key.
124+
* @param {string} [options.buildMode]
125+
* @param {string} [options.nodeVersion]
126+
* @param {string} [options.platform]
127+
* @param {string} [options.arch]
128+
* @param {string} [options.libc]
129+
* @param {string} [options.packageRoot]
130+
*/
131+
export async function createCheckpoint(
132+
buildDir,
133+
name,
134+
smokeTest,
135+
options = {},
136+
) {
137+
if (typeof smokeTest !== 'function') {
138+
throw new Error(
139+
`createCheckpoint('${name}'): expected smokeTest callback as argument 3, got ${typeof smokeTest}.`,
140+
)
141+
}
142+
143+
const {
144+
arch,
145+
artifactPath,
146+
binaryPath,
147+
binarySize,
148+
buildMode,
149+
libc,
150+
nodeVersion,
151+
packageName = '',
152+
packageRoot,
153+
platform,
154+
sourcePaths,
155+
} = options
156+
157+
try {
158+
await smokeTest()
159+
} catch (e) {
160+
throw new Error(
161+
`Smoke test failed for checkpoint '${name}': ${errorMessage(e)}`,
162+
{ cause: e },
163+
)
164+
}
165+
166+
const dir = checkpointDir(buildDir, packageName)
167+
await safeMkdir(dir)
168+
169+
const cacheHash = computeCacheHash(sourcePaths, {
170+
arch,
171+
buildMode,
172+
libc,
173+
nodeVersion,
174+
platform,
175+
})
176+
177+
const data = {
178+
name,
179+
createdAt: new Date().toISOString(),
180+
cacheHash,
181+
artifactPath,
182+
binaryPath,
183+
binarySize,
184+
platform,
185+
arch,
186+
libc,
187+
buildMode,
188+
nodeVersion,
189+
}
190+
191+
const file = checkpointFile(buildDir, packageName, name)
192+
await fs.writeFile(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
193+
194+
const relRoot = packageRoot ? path.relative(packageRoot, file) : file
195+
logger.substep(`✓ Checkpoint ${name} written (${relRoot})`)
196+
}
197+
198+
/**
199+
* Should the stage run? True if force, no checkpoint, missing cache hash,
200+
* or the hash no longer matches current inputs.
201+
*/
202+
export async function shouldRun(
203+
buildDir,
204+
packageName,
205+
name,
206+
force = false,
207+
sourcePaths,
208+
options = {},
209+
) {
210+
if (force) {
211+
return true
212+
}
213+
if (!hasCheckpoint(buildDir, packageName, name)) {
214+
return true
215+
}
216+
217+
// Only validate hash if the caller provided inputs or platform metadata.
218+
const wantsValidation =
219+
(sourcePaths && sourcePaths.length) ||
220+
options.buildMode ||
221+
options.platform ||
222+
options.arch
223+
224+
if (!wantsValidation) {
225+
return false
226+
}
227+
228+
const data = await getCheckpointData(buildDir, packageName, name)
229+
if (!data) {
230+
return true
231+
}
232+
233+
const expected = computeCacheHash(sourcePaths, options)
234+
if (!data.cacheHash || data.cacheHash !== expected) {
235+
logger.substep(`Checkpoint ${name} stale (cache hash changed) — rebuilding`)
236+
return true
237+
}
238+
239+
return false
240+
}
241+
242+
/**
243+
* Delete all checkpoints under a build dir (or a single package's scope).
244+
*/
245+
export async function cleanCheckpoint(buildDir, packageName) {
246+
const dir = checkpointDir(buildDir, packageName)
247+
if (!existsSync(dir)) {
248+
return
249+
}
250+
await safeDelete(dir)
251+
logger.substep('Checkpoints cleaned')
252+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Shared constants for the build-pipeline orchestrator (socket-cli variant).
3+
*
4+
* Mirrors the socket-btm/ultrathink/socket-tui/sdxgen API surface
5+
* (BUILD_STAGES, CHECKPOINTS, CHECKPOINT_CHAINS, validateCheckpointChain,
6+
* getBuildMode). socket-cli doesn't build wasm — it consumes pre-built
7+
* wasm + node binaries from socket-btm — so the orchestrator name
8+
* ('build-pipeline') is historical; the machinery is build-type-agnostic.
9+
*/
10+
11+
import process from 'node:process'
12+
13+
import { getCI } from '@socketsecurity/lib/env/ci'
14+
15+
/**
16+
* Build stage directory names inside build/<mode>/.
17+
*/
18+
export const BUILD_STAGES = {
19+
BUNDLED: 'Bundled',
20+
FINAL: 'Final',
21+
OPTIMIZED: 'Optimized',
22+
RELEASE: 'Release',
23+
STRIPPED: 'Stripped',
24+
SEA: 'Sea',
25+
SYNC: 'Sync',
26+
TYPES: 'Types',
27+
}
28+
29+
/**
30+
* Canonical checkpoint names. Each pipeline stage picks one.
31+
*/
32+
export const CHECKPOINTS = {
33+
CLI: 'cli',
34+
FINALIZED: 'finalized',
35+
SEA: 'sea',
36+
}
37+
38+
const VALID_CHECKPOINT_VALUES = new Set(Object.values(CHECKPOINTS))
39+
40+
/**
41+
* Checkpoint chain for socket-cli's build pipeline.
42+
* Order: newest → oldest (matching socket-btm convention).
43+
*
44+
* The SEA binary is built only for --force / --prod today; the chain is
45+
* declared including SEA so --clean-stage=sea works when it runs.
46+
*/
47+
export const CHECKPOINT_CHAINS = {
48+
cli: () => [CHECKPOINTS.FINALIZED, CHECKPOINTS.SEA, CHECKPOINTS.CLI],
49+
}
50+
51+
/**
52+
* Validate a checkpoint chain at runtime.
53+
*/
54+
export function validateCheckpointChain(
55+
chain: string[],
56+
packageName: string,
57+
) {
58+
if (!Array.isArray(chain)) {
59+
throw new Error(`${packageName}: Checkpoint chain must be an array`)
60+
}
61+
if (chain.length === 0) {
62+
throw new Error(`${packageName}: Checkpoint chain cannot be empty`)
63+
}
64+
const invalid = chain.filter(cp => !VALID_CHECKPOINT_VALUES.has(cp))
65+
if (invalid.length) {
66+
throw new Error(
67+
`${packageName}: Invalid checkpoint names in chain: ${invalid.join(', ')}. ` +
68+
`Valid: ${Object.keys(CHECKPOINTS).join(', ')}`,
69+
)
70+
}
71+
const seen = new Set()
72+
for (const cp of chain) {
73+
if (seen.has(cp)) {
74+
throw new Error(`${packageName}: Duplicate checkpoint in chain: ${cp}`)
75+
}
76+
seen.add(cp)
77+
}
78+
}
79+
80+
// Validate chain registry at module load.
81+
for (const [name, generator] of Object.entries(CHECKPOINT_CHAINS)) {
82+
validateCheckpointChain(generator(), `CHECKPOINT_CHAINS.${name}`)
83+
}
84+
85+
/**
86+
* Resolve the build mode from CLI flags, env, or CI autodetect.
87+
*/
88+
export function getBuildMode(args?: string[] | Set<string>): string {
89+
if (args) {
90+
const has = Array.isArray(args)
91+
? (flag: string) => args.includes(flag)
92+
: (flag: string) => args.has(flag)
93+
if (has('--prod')) {
94+
return 'prod'
95+
}
96+
if (has('--dev')) {
97+
return 'dev'
98+
}
99+
}
100+
if (process.env['BUILD_MODE']) {
101+
return process.env['BUILD_MODE']
102+
}
103+
return getCI() ? 'prod' : 'dev'
104+
}
105+
106+
/**
107+
* Path used by platform-mappings.isMusl() for Alpine detection.
108+
*/
109+
export const ALPINE_RELEASE_FILE = '/etc/alpine-release'

0 commit comments

Comments
 (0)