diff --git a/CLAUDE.md b/CLAUDE.md index 52931fc0..fe13adb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,5 +109,5 @@ ## 🏗️ node-smol-Specific - node-smol owns Socket's customized Node.js distribution: source patches, builtins, SEA packaging, platform artifacts, and release assembly; binaries ship as GitHub release assets, never npm. -- Reusable native capabilities stay Rust-canonical in their owner repos — `upstream/` holds shallow, pinned source references (`.gitmodules` `ref`+`sha256:` pins), and adapters consume them through a `.node` addon or a narrow `node:smol-*` builtin contract. +- Reusable native capabilities stay Rust-canonical in their owner repos — `upstream/` holds shallow single-branch source references, and adapters consume them through a `.node` addon or a narrow `node:smol-*` builtin contract. ACTUAL pin shape today: `.gitmodules` carries `branch = main` + `shallow = true` only (stuie publishes no release tags, so there is no `ref` and no `sha256:` yet). TODO: adopt the intended `ref`+`sha256:` pin contract (set via `gen/gitmodules-hash --set`) once stuie ships a taggable release — until then, do not describe the pins as content-addressed. - The Node runtime pin cascades from the wheelhouse (`.node-version` + the fleet setup-action defaults + the node-base builder image) — never hand-edit it here; bump via `cascade-fleet.mts --node`. diff --git a/README.md b/README.md index a5551d9a..6b64a17f 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,26 @@ references through a `.node` addon or a narrow `node:smol-*` builtin contract. ## Install -Binaries ship as GitHub release assets — download the artifact for your -platform from the releases page. +**There are no releases yet** — this repo has published zero GitHub releases, +so there is nothing to download today. Once the first release is cut, binaries +will ship as GitHub release assets: ```sh +# Not functional yet: SocketDev/node-smol has no releases (checked 2026-07-28). gh release download --repo SocketDev/node-smol ``` +Until then, the build lane is `scripts/repo/build.mts`: + +```sh +node scripts/repo/build.mts --dry-run # print the exact build plan +node scripts/repo/build.mts # bake the compile-environment image +node scripts/repo/build.mts --target binary # fails loud: the binary lane is not ported yet +``` + +Release assembly lives in `scripts/repo/release.mts` (dry-run by default; +`--publish` cuts a draft release via `gh release create --draft`). + ## Usage Run the downloaded binary in place of `node`: diff --git a/package.json b/package.json index 0172678e..ca1d2f68 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "scripts": { "build": "node scripts/fleet/build-hook-bundle.mts", + "build:repo": "node scripts/repo/build.mts", "check": "node scripts/fleet/check.mts", "check:paths": "node scripts/fleet/check/paths-are-canonical.mts", "ci:local": "node scripts/fleet/agent-ci-skip-locks.mts run --all --quiet --pause-on-failure --github-token", @@ -30,6 +31,7 @@ "lockstep:emit-mirror-globs": "node scripts/fleet/lockstep-emit-mirror-globs.mts", "lockstep:emit-schema": "node scripts/fleet/lockstep-emit-schema.mts", "prepare": "node scripts/fleet/install-git-hooks.mts && node scripts/fleet/prepare.mts", + "release": "node scripts/repo/release.mts", "security": "node scripts/fleet/security.mts", "sync-package-manager-pins": "node scripts/fleet/sync-package-manager-pins.mts", "test": "node scripts/fleet/test.mts", diff --git a/scripts/repo/build.mts b/scripts/repo/build.mts new file mode 100644 index 00000000..44aab447 --- /dev/null +++ b/scripts/repo/build.mts @@ -0,0 +1,313 @@ +/* + * @file Repo build lane entry. `node scripts/repo/build.mts` orchestrates what + * this repo can actually build TODAY, and refuses loudly to pretend about + * the rest. + * Targets: + * + * - `--target base` (default) — REAL. Bakes the compile-environment image(s) + * declared in `.config/repo/socket-wheelhouse.json` `docker.prebakes` via + * `docker buildx build`, exactly like the prebake-publish workflow + * (.github/workflows/prebake-publish.yml) does on dispatch. Local default + * builds the host platform only and `--load`s it into the docker daemon; + * `--push` builds every declared platform and pushes to GHCR (needs a + * `docker login ghcr.io` with packages:write — the workflow dispatch is the + * canonical publisher, this flag exists for break-glass use). + * - `--target binary` — NOT PORTED YET. The node-smol binary build (Node source + * checkout + Socket patch series + builtins + SEA packaging + + * strip/compress) still lives in socket-btm's history; see the fail-loud + * message below for exactly what is missing and where the sources are. + * `--dry-run` prints the exact commands with their preconditions instead of + * running them. USAGE: node scripts/repo/build.mts [--target base|binary] + * [--dry-run] [--push] [--platforms linux/arm64,...] + */ + +import { existsSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { parseArgs } from 'node:util' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +import { isMainModule } from '../fleet/_shared/is-main-module.mts' +import { runCapture, runInherit } from '../fleet/publish-infra/shared.mts' +import { loadSocketWheelhouseConfig, REPO_ROOT } from './paths.mts' + +const logger = getDefaultLogger() + +/** + * One `docker.prebakes.prebakes[]` entry, narrowed from the untyped config + * (the fleet loader validates "JSON object" only — schema validation is + * wheelhouse-side). + */ +export interface PrebakeEntry { + dockerfile: string + name: string + platforms: string[] +} + +export interface PrebakePlan { + entries: PrebakeEntry[] + registry: string +} + +/** + * Type guard for a plain JSON object — keeps the config walk free of type + * assertions. + */ +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +/** + * Extract the prebake plan from a parsed socket-wheelhouse.json root. Returns + * an error string (for fail-loud reporting) when the shape is not usable. + */ +export function extractPrebakePlan( + configValue: Record, +): PrebakePlan | string { + const docker = configValue['docker'] + if (!isRecord(docker)) { + return 'config has no docker section' + } + const prebakes = docker['prebakes'] + if (!isRecord(prebakes)) { + return 'config has no docker.prebakes section' + } + const registry = prebakes['registry'] + if (typeof registry !== 'string' || !registry) { + return 'docker.prebakes.registry is missing' + } + const rawEntries = prebakes['prebakes'] + if (!Array.isArray(rawEntries) || rawEntries.length === 0) { + return 'docker.prebakes.prebakes declares no images' + } + const entries: PrebakeEntry[] = [] + for (let i = 0, { length } = rawEntries; i < length; i += 1) { + const entry: unknown = rawEntries[i] + if (!isRecord(entry)) { + return `docker.prebakes.prebakes[${i}] is not an object` + } + const name = entry['name'] + const dockerfile = entry['dockerfile'] + if (typeof name !== 'string' || typeof dockerfile !== 'string') { + return `docker.prebakes.prebakes[${i}] is missing name/dockerfile` + } + const rawPlatforms = entry['platforms'] + const platforms = Array.isArray(rawPlatforms) + ? rawPlatforms.filter((p): p is string => typeof p === 'string') + : ['linux/amd64', 'linux/arm64'] + entries.push({ dockerfile, name, platforms }) + } + return { entries, registry } +} + +/** + * The docker platform string for the machine we are on — what a no-`--push` + * build can actually `--load` into the local daemon (buildx refuses to + * `--load` a multi-platform result). + */ +export function hostDockerPlatform(): string { + return os.arch() === 'arm64' ? 'linux/arm64' : 'linux/amd64' +} + +export interface BuildxArgsOptions { + push?: boolean | undefined +} + +/** + * Build the exact `docker buildx build` argv for one prebake entry — shared + * between dry-run printing and real execution so the printed plan can never + * drift from what runs. + */ +export function buildxArgs( + entry: PrebakeEntry, + registry: string, + platforms: string, + options?: BuildxArgsOptions | undefined, +): string[] { + const opts = { __proto__: null, ...options } as BuildxArgsOptions + const push = opts.push ?? false + return [ + 'buildx', + 'build', + '-f', + entry.dockerfile, + '-t', + `${registry}/${entry.name}:latest`, + '--platform', + platforms, + push ? '--push' : '--load', + '.', + ] +} + +const BINARY_LANE_MISSING = `build.mts --target binary: no binary lane exists in this repo yet — refusing to fake one. + What: the node-smol binary build (pinned Node source checkout, Socket patch + series, builtins/additions tree, SEA packaging, strip + compress, + platform artifact naming) has not been extracted into this repo. + Where: SocketDev/socket-btm .config/repo/node-smol-rust-extraction.json + marks packages/node-smol-builder and packages/npm as destined for + node-smol with removalStatus "removed" — the sources were dropped + from socket-btm (refactor(btm)!, commit 40b7e82a) before landing + here. The pre-drop tree is socket-btm@55f8f23a + packages/node-smol-builder (entry: scripts/common/shared/build.mts). + Saw: this repo carries no Node source pin, no Node patch series (patches/ + holds only a pnpm tool patch), no additions/ builtins tree, and no + SEA/strip/compress scripts. Only the compile-environment image + (--target base) is buildable today. + Fix: port the builder from socket-btm@55f8f23a packages/node-smol-builder, + then wire this target to its entry script. Track the port before + cutting any GitHub release (scripts/repo/release.mts).` + +interface BaseTargetOptions { + dryRun?: boolean | undefined + platformsOverride?: string | undefined + push?: boolean | undefined +} + +/** + * Bake the declared prebake images. Returns the process exit code. + */ +async function runBaseTarget( + options?: BaseTargetOptions | undefined, +): Promise { + const opts = { __proto__: null, ...options } as BaseTargetOptions + const dryRun = opts.dryRun ?? false + const { platformsOverride } = opts + const push = opts.push ?? false + const loaded = loadSocketWheelhouseConfig(REPO_ROOT) + if (!loaded) { + logger.error( + 'build.mts: cannot load .config/repo/socket-wheelhouse.json.\n' + + ' What: the prebake declarations (registry, dockerfile, platforms) live there.\n' + + ' Where: .config/repo/socket-wheelhouse.json (single source of truth,\n' + + ' shared with .github/workflows/prebake-publish.yml).\n' + + ' Saw: file absent or unparseable.\n' + + ' Fix: restore the config; do not hand-inline image names here.', + ) + return 1 + } + const plan = extractPrebakePlan(loaded.value) + if (typeof plan === 'string') { + logger.error( + `build.mts: unusable prebake config — ${plan}.\n` + + ` Where: ${loaded.location.path}\n` + + ' Fix: repair the docker.prebakes section to match the wheelhouse schema.', + ) + return 1 + } + + for (let i = 0, { length } = plan.entries; i < length; i += 1) { + const entry = plan.entries[i]! + const dockerfileAbs = path.join(REPO_ROOT, entry.dockerfile) + if (!existsSync(dockerfileAbs)) { + logger.error( + `build.mts: declared Dockerfile is missing — ${entry.dockerfile}.\n` + + ` Where: ${loaded.location.path} entry "${entry.name}"\n` + + ' Fix: restore the Dockerfile or fix the declaration.', + ) + return 1 + } + const platforms = + platformsOverride ?? + (push ? entry.platforms.join(',') : hostDockerPlatform()) + if (!push && platforms.includes(',')) { + logger.error( + 'build.mts: buildx cannot --load a multi-platform build.\n' + + ` Saw: --platforms ${platforms} without --push.\n` + + ' Fix: pass a single platform, or add --push (needs docker login\n' + + ' ghcr.io with packages:write).', + ) + return 1 + } + const args = buildxArgs(entry, plan.registry, platforms, { push }) + if (dryRun) { + logger.log(`plan [${entry.name}]: docker ${args.join(' ')}`) + logger.log(` cwd: ${REPO_ROOT}`) + logger.log( + ' preconditions: docker CLI + buildx plugin installed;' + + ' the FROM image (FLEET_BASE arg default,' + + ' ghcr.io/socketdev/socket-wheelhouse/c-base:latest) must be pullable', + ) + if (push) { + logger.log( + ' --push additionally needs docker login ghcr.io with' + + ' packages:write (canonical publisher: dispatch' + + ' .github/workflows/prebake-publish.yml instead)', + ) + } + continue + } + const dockerProbe = await runCapture('docker', ['--version'], REPO_ROOT) + if (dockerProbe.code !== 0) { + logger.error( + 'build.mts: docker CLI not found on PATH.\n' + + ' Fix: install Docker (or run the bake in CI by dispatching\n' + + ' .github/workflows/prebake-publish.yml).', + ) + return 1 + } + const buildxProbe = await runCapture( + 'docker', + ['buildx', 'version'], + REPO_ROOT, + ) + if (buildxProbe.code !== 0) { + logger.error( + 'build.mts: docker buildx plugin not available.\n' + + ' Fix: install the buildx plugin (ships with Docker Desktop;\n' + + ' docker-buildx-plugin on Linux).', + ) + return 1 + } + logger.log(`baking ${entry.name} (${platforms})…`) + const code = await runInherit('docker', args, REPO_ROOT) + if (code !== 0) { + logger.error(`build.mts: docker buildx exited ${code} for ${entry.name}`) + return code + } + logger.success(`built ${plan.registry}/${entry.name}:latest`) + } + return 0 +} + +export async function main(): Promise { + const { values } = parseArgs({ + allowPositionals: false, + args: process.argv.slice(2), + options: { + 'dry-run': { default: false, type: 'boolean' }, + platforms: { type: 'string' }, + push: { default: false, type: 'boolean' }, + target: { default: 'base', type: 'string' }, + }, + }) + const target = values.target + if (target === 'binary') { + logger.error(BINARY_LANE_MISSING) + process.exitCode = 1 + return + } + if (target !== 'base') { + logger.error( + `build.mts: unknown --target ${JSON.stringify(target)} — valid targets: base, binary.`, + ) + process.exitCode = 1 + return + } + process.exitCode = await runBaseTarget({ + dryRun: values['dry-run'], + platformsOverride: values.platforms, + push: values.push, + }) +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not execute the script. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(e) + process.exitCode = 1 + }) +} diff --git a/scripts/repo/paths.mts b/scripts/repo/paths.mts new file mode 100644 index 00000000..efa4de09 --- /dev/null +++ b/scripts/repo/paths.mts @@ -0,0 +1,28 @@ +/* + * @file Canonical paths for the repo-tier build/release scripts. Inherits the + * fleet paths (1 path, 1 reference) and adds the node-smol-specific build + * output locations. The Dockerfile path is deliberately NOT constructed + * here: `.config/repo/socket-wheelhouse.json` `docker.prebakes[].dockerfile` + * is its single source of truth (the prebake-publish workflow reads the same + * field), so `build.mts` resolves it from the loaded config instead of a + * second hand-maintained copy. + */ + +import path from 'node:path' + +import { REPO_ROOT } from '../fleet/paths.mts' + +export * from '../fleet/paths.mts' + +/** + * Root of untracked build outputs (fleet rule: build outputs live under + * `/build/`, never git-tracked). + */ +export const BUILD_DIR = path.join(REPO_ROOT, 'build') + +/** + * Default staging directory `release.mts` reads assets from. Empty until the + * binary build lane exists — `build.mts --target binary` names what is + * missing. + */ +export const RELEASE_ASSETS_DIR = path.join(BUILD_DIR, 'release') diff --git a/scripts/repo/release.mts b/scripts/repo/release.mts new file mode 100644 index 00000000..2fa69eb5 --- /dev/null +++ b/scripts/repo/release.mts @@ -0,0 +1,254 @@ +/* + * @file Repo release-assembly lane. Assembles GitHub release assets from a + * built output directory, writes a SHA-256 `checksums.txt` manifest, and + * cuts a DRAFT release via `gh release create --draft`. Honesty rules: + * + * - DRY-RUN IS THE DEFAULT. Without `--publish` it prints the exact `gh release + * create` command, the computed per-asset digests, and every precondition — + * nothing is uploaded. + * - The repo has ZERO releases today and no binary build lane + * (`scripts/repo/build.mts --target binary` names what is missing), so + * until that lane is ported the asset directory must be populated by hand + * or by CI — this script fails loud when it is empty rather than cutting an + * assetless release. + * - The release is created as a DRAFT. Undrafting via `gh release edit + * --draft=false` is a separate deliberate act — fleet releases are + * immutable once public, and the github-release workflow's order rule + * applies: publish gates run before the final release marker exists. USAGE: + * node scripts/repo/release.mts --tag v0.1.0 [--dir build/release] + * [--notes-file ] [--publish] + */ + +import crypto from 'node:crypto' +import { + createReadStream, + existsSync, + readdirSync, + writeFileSync, +} from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { pipeline } from 'node:stream/promises' +import { parseArgs } from 'node:util' + +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' + +import { isMainModule } from '../fleet/_shared/is-main-module.mts' +import { runCapture, runInherit } from '../fleet/publish-infra/shared.mts' +import { RELEASE_ASSETS_DIR, REPO_ROOT } from './paths.mts' + +const logger = getDefaultLogger() + +const CHECKSUMS_BASENAME = 'checksums.txt' + +const TAG_PATTERN = /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ + +/** + * Recursively list the release-asset files under `dir`, excluding a previously + * generated checksums manifest — that file is regenerated every run. Returns + * absolute paths, sorted for deterministic manifests. + */ +export function collectAssets(dir: string): string[] { + const dirents = readdirSync(dir, { recursive: true, withFileTypes: true }) + const files: string[] = [] + for (let i = 0, { length } = dirents; i < length; i += 1) { + const dirent = dirents[i]! + if (!dirent.isFile() || dirent.name === CHECKSUMS_BASENAME) { + continue + } + files.push(path.join(dirent.parentPath, dirent.name)) + } + return files.toSorted() +} + +/** + * SHA-256 of one file, streamed (release binaries are tens of MB). + */ +export async function sha256File(file: string): Promise { + const hash = crypto.createHash('sha256') + await pipeline(createReadStream(file), hash) + return hash.digest('hex') +} + +/** + * Render the `checksums.txt` body — `shasum -a 256 -c` compatible + * (``), one line per asset, sorted by name. + */ +export function renderChecksums( + digests: Array<{ digest: string; file: string }>, +): string { + return `${digests + .map(entry => `${entry.digest} ${path.basename(entry.file)}`) + .join('\n')}\n` +} + +const EMPTY_DIR_MESSAGE = (dir: string) => + `release.mts: no release assets found — refusing to cut an empty release. + What: the assembly step needs built platform artifacts to upload. + Where: ${dir} + Saw: directory missing or contains no files. + Fix: this repo has no binary build lane yet — run + node scripts/repo/build.mts --target binary for exactly what is + missing and where the socket-btm sources are. Once artifacts exist, + place them under the directory above (or pass --dir) and re-run.` + +export async function main(): Promise { + const { values } = parseArgs({ + allowPositionals: false, + args: process.argv.slice(2), + options: { + dir: { type: 'string' }, + 'notes-file': { type: 'string' }, + publish: { default: false, type: 'boolean' }, + tag: { type: 'string' }, + }, + }) + const publish = values.publish + const dir = values.dir + ? path.resolve(REPO_ROOT, values.dir) + : RELEASE_ASSETS_DIR + + const tag = values.tag + if (!tag || !TAG_PATTERN.test(tag)) { + logger.error( + `release.mts: --tag is required and must look like v1.2.3 (saw ${JSON.stringify(tag ?? '')}).\n` + + ' Fix: pass --tag vX.Y.Z (the USER names the version — never invent one).', + ) + process.exitCode = 1 + return + } + + if (!existsSync(dir)) { + logger.error(EMPTY_DIR_MESSAGE(dir)) + process.exitCode = 1 + return + } + const assets = collectAssets(dir) + if (assets.length === 0) { + logger.error(EMPTY_DIR_MESSAGE(dir)) + process.exitCode = 1 + return + } + + const digests: Array<{ digest: string; file: string }> = [] + for (let i = 0, { length } = assets; i < length; i += 1) { + const file = assets[i]! + // oxlint-disable-next-line no-await-in-loop -- sequential hashing keeps memory flat and the manifest ordering obvious. + digests.push({ digest: await sha256File(file), file }) + } + const checksumsPath = path.join(dir, CHECKSUMS_BASENAME) + const checksumsBody = renderChecksums(digests) + + const notesFile = values['notes-file'] + if (notesFile && !existsSync(path.resolve(REPO_ROOT, notesFile))) { + logger.error( + `release.mts: --notes-file does not exist: ${notesFile}\n` + + ' Fix: pass a readable notes file, or omit for generated notes.', + ) + process.exitCode = 1 + return + } + const notesArgs = notesFile + ? ['--notes-file', path.resolve(REPO_ROOT, notesFile)] + : [ + '--notes', + `node-smol ${tag} — platform binaries plus a SHA-256 manifest (${CHECKSUMS_BASENAME}). Verify downloads with: shasum -a 256 -c ${CHECKSUMS_BASENAME}`, + ] + const ghArgs = [ + 'release', + 'create', + tag, + '--draft', + '--title', + tag, + ...notesArgs, + ...assets, + checksumsPath, + ] + + logger.log(`assets (${assets.length}) from ${dir}:`) + for (let i = 0, { length } = digests; i < length; i += 1) { + const entry = digests[i]! + logger.log(` ${entry.digest} ${path.relative(dir, entry.file)}`) + } + + if (!publish) { + logger.log('') + logger.log( + `plan (dry-run — pass --publish to execute): gh ${ghArgs.join(' ')}`, + ) + logger.log(` cwd: ${REPO_ROOT}`) + logger.log( + ` would write: ${checksumsPath} (${digests.length} line${digests.length === 1 ? '' : 's'})`, + ) + logger.log( + ' preconditions: gh CLI authenticated with repo write access;' + + ` no existing release for ${tag} (verify: gh release view ${tag});` + + ' the version was named by the user', + ) + logger.log( + ` follow-up: gh release edit ${tag} --draft=false (deliberate, separate act — publishing makes the release immutable)`, + ) + return + } + + // Real cut. Verify state before acting (fleet rule): the gh CLI must be + // authenticated, and the release must not already exist. + const ghProbe = await runCapture('gh', ['--version'], REPO_ROOT) + if (ghProbe.code !== 0) { + logger.error('release.mts: gh CLI not found on PATH.') + logger.error( + ' Fix: install the GitHub CLI from https://cli.github.com and run gh auth login.', + ) + process.exitCode = 1 + return + } + const authProbe = await runCapture('gh', ['auth', 'status'], REPO_ROOT) + if (authProbe.code !== 0) { + logger.error('release.mts: gh CLI is not authenticated.') + logger.error( + ' Fix: gh auth login — cutting releases needs repo write access.', + ) + process.exitCode = 1 + return + } + const existing = await runCapture( + 'gh', + ['release', 'view', tag, '--json', 'tagName,isDraft'], + REPO_ROOT, + ) + if (existing.code === 0) { + logger.error( + `release.mts: a release for ${tag} already exists — refusing to double-cut.\n` + + ` Saw: gh release view ${tag} resolved: ${existing.stdout.trim()}\n` + + ' Fix: pick a new version (the USER names it), or edit the existing\n' + + ' draft via gh release upload / gh release edit.', + ) + process.exitCode = 1 + return + } + + writeFileSync(checksumsPath, checksumsBody) + logger.log(`wrote ${checksumsPath}`) + const code = await runInherit('gh', ghArgs, REPO_ROOT) + if (code !== 0) { + logger.error(`release.mts: gh release create exited ${code}`) + process.exitCode = code + return + } + logger.success( + `draft release ${tag} created with ${assets.length + 1} assets`, + ) + logger.log( + `Next: gh release edit ${tag} --draft=false — a separate deliberate act; public releases are immutable.`, + ) +} + +// Entrypoint-guarded: importing this module (unit tests of its exported +// helpers) must not execute the script. +if (isMainModule(import.meta.url)) { + main().catch((e: unknown) => { + logger.error(e) + process.exitCode = 1 + }) +}