Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
492 changes: 492 additions & 0 deletions packages/build-infra/lib/build-pipeline.mts

Large diffs are not rendered by default.

252 changes: 252 additions & 0 deletions packages/build-infra/lib/checkpoint-manager.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/**
* Build checkpoint manager (lean).
*
* Same public API as socket-btm's checkpoint-manager but sized for the
* single-stage wasm builds in this repo (lang/{rust,cpp,go}). Each stage
* writes a JSON marker `{ name }.json` keyed by a content hash of its
* source inputs + platform/arch/mode. If the hash matches next run, the
* stage is skipped.
*
* What this intentionally omits vs socket-btm:
* - Tarball archival (socket-btm archives the built artifact so CI can
* restore it between jobs; lang wasm rebuilds take seconds, not 30 min).
* - Ad-hoc macOS codesign (wasm artifacts don't need it).
* - Cross-process atomic-write ceremony (no concurrent CI jobs racing on
* the same build dir in this repo).
* - restoreCheckpoint (nothing to restore when there's no tarball).
*
* Exports mirror the names build-pipeline consumes, so the orchestrator is
* identical across repos.
*/

import { createHash } from 'node:crypto'
import { existsSync, promises as fs, readFileSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'

import { errorMessage } from '@socketsecurity/lib/errors'
import { safeDelete, safeMkdir } from '@socketsecurity/lib/fs'
import { getDefaultLogger } from '@socketsecurity/lib/logger'

const logger = getDefaultLogger()

function checkpointDir(buildDir, packageName) {
return packageName
? path.join(buildDir, 'checkpoints', packageName)
: path.join(buildDir, 'checkpoints')
}

function checkpointFile(buildDir, packageName, name) {
return path.join(checkpointDir(buildDir, packageName), `${name}.json`)
}

function hashSourcePaths(sourcePaths) {
const hash = createHash('sha256')
for (const file of [...sourcePaths].sort()) {
hash.update(`${file}:`)
if (existsSync(file)) {
try {
hash.update(readFileSync(file))
} catch (e) {
if (e.code !== 'ENOENT') {
throw e
}
}
}
}
return hash.digest('hex')
}

function platformCacheKey({ buildMode, nodeVersion, platform, arch, libc }) {
const parts = [
buildMode && `mode=${buildMode}`,
nodeVersion && `node=${nodeVersion}`,
platform && `platform=${platform}`,
arch && `arch=${arch}`,
libc && `libc=${libc}`,
].filter(Boolean)
if (!parts.length) {
return ''
}
return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16)
}

function computeCacheHash(sourcePaths, options) {
const sourcesHash = sourcePaths?.length ? hashSourcePaths(sourcePaths) : ''
const platformHash = platformCacheKey(options || {})
if (!sourcesHash && !platformHash) {
return ''
}
return createHash('sha256')
.update(sourcesHash)
.update('|')
.update(platformHash)
.digest('hex')
}

/**
* Does a checkpoint JSON marker exist?
*/
export function hasCheckpoint(buildDir, packageName, name) {
return existsSync(checkpointFile(buildDir, packageName, name))
}

/**
* Read a checkpoint's JSON data, or undefined if it does not exist.
*/
export async function getCheckpointData(buildDir, packageName, name) {
const file = checkpointFile(buildDir, packageName, name)
if (!existsSync(file)) {
return undefined
}
try {
return JSON.parse(await fs.readFile(file, 'utf8'))
} catch (e) {
logger.warn(
`Checkpoint ${name} JSON unreadable (${errorMessage(e)}) — ignoring`,
)
return undefined
}
}

/**
* Run `smokeTest`, then write a checkpoint JSON marker.
*
* @param {string} buildDir
* @param {string} name - Checkpoint name (must be a CHECKPOINTS value).
* @param {() => Promise<void>} smokeTest - Throws if the stage output is invalid.
* @param {object} [options]
* @param {string} [options.packageName]
* @param {string} [options.artifactPath] - Informational; recorded in JSON.
* @param {string} [options.binaryPath] - Informational; recorded in JSON.
* @param {string|number} [options.binarySize] - Informational; recorded in JSON.
* @param {string[]} [options.sourcePaths] - Inputs hashed into the cache key.
* @param {string} [options.buildMode]
* @param {string} [options.nodeVersion]
* @param {string} [options.platform]
* @param {string} [options.arch]
* @param {string} [options.libc]
* @param {string} [options.packageRoot]
*/
export async function createCheckpoint(
buildDir,
name,
smokeTest,
options = {},
) {
if (typeof smokeTest !== 'function') {
throw new Error(
`createCheckpoint('${name}'): expected smokeTest callback as argument 3, got ${typeof smokeTest}.`,
)
}

const {
arch,
artifactPath,
binaryPath,
binarySize,
buildMode,
libc,
nodeVersion,
packageName = '',
packageRoot,
platform,
sourcePaths,
} = options

try {
await smokeTest()
} catch (e) {
throw new Error(
`Smoke test failed for checkpoint '${name}': ${errorMessage(e)}`,
{ cause: e },
)
}

const dir = checkpointDir(buildDir, packageName)
await safeMkdir(dir)

const cacheHash = computeCacheHash(sourcePaths, {
arch,
buildMode,
libc,
nodeVersion,
platform,
})

const data = {
name,
createdAt: new Date().toISOString(),
cacheHash,
artifactPath,
binaryPath,
binarySize,
platform,
arch,
libc,
buildMode,
nodeVersion,
}

const file = checkpointFile(buildDir, packageName, name)
await fs.writeFile(file, `${JSON.stringify(data, null, 2)}\n`, 'utf8')

const relRoot = packageRoot ? path.relative(packageRoot, file) : file
logger.substep(`✓ Checkpoint ${name} written (${relRoot})`)
}

/**
* Should the stage run? True if force, no checkpoint, missing cache hash,
* or the hash no longer matches current inputs.
*/
export async function shouldRun(
buildDir,
packageName,
name,
force = false,
sourcePaths,
options = {},
) {
if (force) {
return true
}
if (!hasCheckpoint(buildDir, packageName, name)) {
return true
}

// Only validate hash if the caller provided inputs or platform metadata.
const wantsValidation =
(sourcePaths && sourcePaths.length) ||
options.buildMode ||
options.platform ||
options.arch

if (!wantsValidation) {
return false
}

const data = await getCheckpointData(buildDir, packageName, name)
if (!data) {
return true
}

const expected = computeCacheHash(sourcePaths, options)
if (!data.cacheHash || data.cacheHash !== expected) {
logger.substep(`Checkpoint ${name} stale (cache hash changed) — rebuilding`)
return true
}

return false
}

/**
* Delete all checkpoints under a build dir (or a single package's scope).
*/
export async function cleanCheckpoint(buildDir, packageName) {
const dir = checkpointDir(buildDir, packageName)
if (!existsSync(dir)) {
return
}
await safeDelete(dir)
logger.substep('Checkpoints cleaned')
}
109 changes: 109 additions & 0 deletions packages/build-infra/lib/constants.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Shared constants for the build-pipeline orchestrator (socket-cli variant).
*
* Mirrors the socket-btm/ultrathink/socket-tui/sdxgen API surface
* (BUILD_STAGES, CHECKPOINTS, CHECKPOINT_CHAINS, validateCheckpointChain,
* getBuildMode). socket-cli doesn't build wasm — it consumes pre-built
* wasm + node binaries from socket-btm — so the orchestrator name
* ('build-pipeline') is historical; the machinery is build-type-agnostic.
*/

import process from 'node:process'

import { getCI } from '@socketsecurity/lib/env/ci'

/**
* Build stage directory names inside build/<mode>/.
*/
export const BUILD_STAGES = {
BUNDLED: 'Bundled',
FINAL: 'Final',
OPTIMIZED: 'Optimized',
RELEASE: 'Release',
STRIPPED: 'Stripped',
SEA: 'Sea',
SYNC: 'Sync',
TYPES: 'Types',
}

/**
* Canonical checkpoint names. Each pipeline stage picks one.
*/
export const CHECKPOINTS = {
CLI: 'cli',
FINALIZED: 'finalized',
SEA: 'sea',
}

const VALID_CHECKPOINT_VALUES = new Set(Object.values(CHECKPOINTS))

/**
* Checkpoint chain for socket-cli's build pipeline.
* Order: newest → oldest (matching socket-btm convention).
*
* The SEA binary is built only for --force / --prod today; the chain is
* declared including SEA so --clean-stage=sea works when it runs.
*/
export const CHECKPOINT_CHAINS = {
cli: () => [CHECKPOINTS.FINALIZED, CHECKPOINTS.SEA, CHECKPOINTS.CLI],
}

/**
* Validate a checkpoint chain at runtime.
*/
export function validateCheckpointChain(
chain: string[],
packageName: string,
) {
if (!Array.isArray(chain)) {
throw new Error(`${packageName}: Checkpoint chain must be an array`)
}
if (chain.length === 0) {
throw new Error(`${packageName}: Checkpoint chain cannot be empty`)
}
const invalid = chain.filter(cp => !VALID_CHECKPOINT_VALUES.has(cp))
if (invalid.length) {
throw new Error(
`${packageName}: Invalid checkpoint names in chain: ${invalid.join(', ')}. ` +
`Valid: ${Object.values(CHECKPOINTS).join(', ')}`,
)
Comment thread
cursor[bot] marked this conversation as resolved.
}
const seen = new Set()
for (const cp of chain) {
if (seen.has(cp)) {
throw new Error(`${packageName}: Duplicate checkpoint in chain: ${cp}`)
}
seen.add(cp)
}
}

// Validate chain registry at module load.
for (const [name, generator] of Object.entries(CHECKPOINT_CHAINS)) {
validateCheckpointChain(generator(), `CHECKPOINT_CHAINS.${name}`)
}

/**
* Resolve the build mode from CLI flags, env, or CI autodetect.
*/
export function getBuildMode(args?: string[] | Set<string>): string {
if (args) {
const has = Array.isArray(args)
? (flag: string) => args.includes(flag)
: (flag: string) => args.has(flag)
if (has('--prod')) {
return 'prod'
}
if (has('--dev')) {
return 'dev'
}
}
if (process.env['BUILD_MODE']) {
return process.env['BUILD_MODE']
}
return getCI() ? 'prod' : 'dev'
}

/**
* Path used by platform-mappings.isMusl() for Alpine detection.
*/
export const ALPINE_RELEASE_FILE = '/etc/alpine-release'
Loading