-
Notifications
You must be signed in to change notification settings - Fork 42
feat(build): port scripts/build.mts to shared build-pipeline orchestrator #1265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
2bf263f
feat(build): port scripts/build.mts to shared build-pipeline orchestr…
jdalton 7ac5366
fix(build-infra): address cursor bugbot findings on build-pipeline
jdalton 3eb36bf
fix(build): SEA stage must hash CLI output into its cache key
jdalton 7946aa1
Merge branch 'main' into jdd/build-pipeline-port
jdalton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(', ')}`, | ||
| ) | ||
| } | ||
| 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' | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.