diff --git a/.changeset/trl-938-native-bun-release-binding.md b/.changeset/trl-938-native-bun-release-binding.md new file mode 100644 index 000000000..7d4b12274 --- /dev/null +++ b/.changeset/trl-938-native-bun-release-binding.md @@ -0,0 +1,5 @@ +--- +"@ontrails/trails": patch +--- + +Expose the native Bun release binding from `@ontrails/trails/release` and keep publish and registry scripts as compatibility wrappers. diff --git a/apps/trails/package.json b/apps/trails/package.json index e3be46239..c605d4105 100644 --- a/apps/trails/package.json +++ b/apps/trails/package.json @@ -40,6 +40,7 @@ "@ontrails/warden": "workspace:^", "@ontrails/wayfinder": "workspace:^", "commander": "catalog:", + "typescript": "^5.9.3", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/trails/src/__tests__/release-bindings.test.ts b/apps/trails/src/__tests__/release-bindings.test.ts new file mode 100644 index 000000000..a5dc74e20 --- /dev/null +++ b/apps/trails/src/__tests__/release-bindings.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test'; + +import { + nativeBunReleaseBinding, + releaseBindingCapabilityValues, + releaseBindingKindValues, + releaseBindingPlacementValues, +} from '../release/index.js'; + +describe('release bindings', () => { + test('declares the native Bun release binding as the built-in default', () => { + expect(releaseBindingKindValues).toEqual(['native', 'adapter']); + expect(releaseBindingPlacementValues).toEqual([ + 'same-package', + 'subpath', + 'extracted', + ]); + expect(releaseBindingCapabilityValues).toEqual([ + 'pack-check', + 'publish', + 'registry-preflight', + ]); + expect(nativeBunReleaseBinding).toEqual({ + boundary: 'trails-owned', + capabilities: ['pack-check', 'publish', 'registry-preflight'], + description: + 'Built-in Bun release binding for Trails-owned package pack checks, npm registry preflight, and lockstep package publication.', + id: 'release.binding.native-bun', + kind: 'native', + placement: 'same-package', + runtime: 'bun', + }); + }); +}); diff --git a/apps/trails/src/release/bindings.ts b/apps/trails/src/release/bindings.ts new file mode 100644 index 000000000..6e1b90ae1 --- /dev/null +++ b/apps/trails/src/release/bindings.ts @@ -0,0 +1,39 @@ +export const releaseBindingKindValues = ['native', 'adapter'] as const; +export type ReleaseBindingKind = (typeof releaseBindingKindValues)[number]; + +export const releaseBindingPlacementValues = [ + 'same-package', + 'subpath', + 'extracted', +] as const; +export type ReleaseBindingPlacement = + (typeof releaseBindingPlacementValues)[number]; + +export const releaseBindingCapabilityValues = [ + 'pack-check', + 'publish', + 'registry-preflight', +] as const; +export type ReleaseBindingCapability = + (typeof releaseBindingCapabilityValues)[number]; + +export interface ReleaseBindingDescriptor { + readonly boundary: 'foreign' | 'trails-owned'; + readonly capabilities: readonly ReleaseBindingCapability[]; + readonly description: string; + readonly id: string; + readonly kind: ReleaseBindingKind; + readonly placement: ReleaseBindingPlacement; + readonly runtime: string; +} + +export const nativeBunReleaseBinding = { + boundary: 'trails-owned', + capabilities: ['pack-check', 'publish', 'registry-preflight'], + description: + 'Built-in Bun release binding for Trails-owned package pack checks, npm registry preflight, and lockstep package publication.', + id: 'release.binding.native-bun', + kind: 'native', + placement: 'same-package', + runtime: 'bun', +} satisfies ReleaseBindingDescriptor; diff --git a/apps/trails/src/release/index.ts b/apps/trails/src/release/index.ts index 8181da7de..3178ec815 100644 --- a/apps/trails/src/release/index.ts +++ b/apps/trails/src/release/index.ts @@ -1,3 +1,13 @@ +export { + nativeBunReleaseBinding, + releaseBindingCapabilityValues, + releaseBindingKindValues, + releaseBindingPlacementValues, + type ReleaseBindingCapability, + type ReleaseBindingDescriptor, + type ReleaseBindingKind, + type ReleaseBindingPlacement, +} from './bindings.js'; export { checkReleaseRules, discoverWorkspaces, @@ -39,3 +49,22 @@ export { type ReleaseRule, type ReleaseRuleInput, } from './config.js'; +export { + findPackedFirstPartyDependencyMismatches, + runNativeBunPublishCli, + type NativeBunPublishOptions, + type NativeBunPublishPackageJson, + type NativeBunPublishWorkspace, +} from './native-bun-publish.js'; +export { + checkRegistryPosture, + discoverRegistryWorkspaces, + formatDistTagSummary, + registryPostureErrors, + runRegistryPreflight, + runRegistryPreflightCli, + type RegistryPreflightOptions, + type RegistryResult, + type RegistryView, + type RegistryWorkspace, +} from './native-bun-registry.js'; diff --git a/apps/trails/src/release/native-bun-publish.ts b/apps/trails/src/release/native-bun-publish.ts new file mode 100644 index 000000000..c5ff6c7b5 --- /dev/null +++ b/apps/trails/src/release/native-bun-publish.ts @@ -0,0 +1,651 @@ +/* oxlint-disable eslint-plugin-jest/require-hook, max-statements, func-style -- release script with module-level flow */ +/** + * Native Bun release binding for public `@ontrails/*` workspace publication. + * + * Auto-discovers workspaces from the root `package.json` `workspaces` field, + * topo-sorts them by `workspace:` dependency edges, enforces manifest-range + * cleanliness on the packed tarball (no `workspace:` / `catalog:` leakage), + * and respects the Changesets prerelease tag by default. + * + * @see docs/tenets.md for release posture. + */ + +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative, resolve } from 'node:path'; + +const REPO_ROOT = resolve(process.cwd()); + +/** ANSI color helpers, disabled when stdout is not a TTY or `NO_COLOR` is set. */ +const useColor = Boolean(process.stdout.isTTY) && !process.env['NO_COLOR']; +const color = (code: string, text: string): string => + useColor ? `\u001B[${code}m${text}\u001B[0m` : text; +const blue = (t: string) => color('0;34', t); +const green = (t: string) => color('0;32', t); +const red = (t: string) => color('0;31', t); + +const info = (msg: string) => console.log(`${blue('▸')} ${msg}`); +const success = (msg: string) => console.log(`${green('✓')} ${msg}`); +const fail = (msg: string) => console.error(`${red('✗')} ${msg}`); + +/** Parsed CLI options. */ +export interface NativeBunPublishOptions { + readonly mode: 'check' | 'publish'; + readonly tag: string | undefined; + readonly otp: string | undefined; + readonly only: readonly string[] | undefined; +} + +/** Minimal shape of a workspace `package.json` we care about. */ +export interface NativeBunPublishPackageJson { + name?: string; + version?: string; + private?: boolean; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; +} + +type DependencyField = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies' + | 'optionalDependencies'; + +/** A discovered, publishable workspace. */ +export interface NativeBunPublishWorkspace { + readonly name: string; + readonly version: string; + readonly path: string; + readonly isPrivate: boolean; + readonly workspaceDeps: readonly string[]; +} + +const DEPENDENCY_FIELDS: readonly DependencyField[] = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + +const USAGE = `Usage: bun scripts/publish.ts [options] + +Publish all public @ontrails/* workspaces in dep order using \`bun publish\`. + +Options: + --check Pre-publish verification only. Runs \`bun pm pack --dry-run\` + (required so \`catalog:\` resolves) and asserts the packed + manifest has no \`workspace:\` or \`catalog:\` ranges. No publishing. + --dry-run Alias for --check. + --tag npm dist-tag. Defaults to .changeset/pre.json tag when in + prerelease mode, otherwise "latest". + --otp Two-factor code. Also read from BUN_PUBLISH_OTP. + --only Restrict to the named packages (repeatable). Useful for + partial reruns after a mid-matrix failure. + -h, --help Show this help and exit. + +Exit codes: 0 success, 1 publish/check failure, 2 arg-parse error.`; + +/** Alphabetical sort helper, hoisted for reuse. */ +const sortAlpha = (names: string[]): string[] => + names.sort((a, b) => a.localeCompare(b)); + +/** Parse CLI args with a tiny hand-rolled parser. Exits with code 2 on error. */ +const parseArgs = (argv: readonly string[]): NativeBunPublishOptions => { + let mode: NativeBunPublishOptions['mode'] = 'publish'; + let tag: string | undefined; + let otp: string | undefined = process.env['BUN_PUBLISH_OTP'] || undefined; + const only: string[] = []; + + const needsValue = (flag: string, value: string | undefined): string => { + if (value === undefined || value.startsWith('--')) { + fail(`${flag} requires a value`); + console.error(USAGE); + process.exit(2); + } + return value; + }; + + let i = 0; + while (i < argv.length) { + const arg = argv[i] as string; + if (arg === '--check' || arg === '--dry-run') { + mode = 'check'; + } else if (arg === '--tag') { + i += 1; + tag = needsValue('--tag', argv[i]); + } else if (arg === '--otp') { + i += 1; + otp = needsValue('--otp', argv[i]); + } else if (arg === '--only') { + i += 1; + const value = needsValue('--only', argv[i]); + for (const name of value + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + only.push(name); + } + } else if (arg === '-h' || arg === '--help') { + console.log(USAGE); + process.exit(0); + } else { + fail(`Unknown argument: ${arg}`); + console.error(USAGE); + process.exit(2); + } + i += 1; + } + + return { + mode, + only: only.length > 0 ? only : undefined, + otp, + tag, + }; +}; + +/** Read and JSON-parse a file. Throws a readable error on failure. */ +const readJson = async (path: string): Promise => { + const file = Bun.file(path); + if (!(await file.exists())) { + throw new Error(`File not found: ${path}`); + } + const text = await file.text(); + try { + return JSON.parse(text) as T; + } catch (error) { + throw new Error(`Invalid JSON in ${path}: ${(error as Error).message}`, { + cause: error, + }); + } +}; + +/** + * Resolve the default dist-tag by inspecting `.changeset/pre.json`. + * Returns the prerelease tag when Changesets is in `pre` mode, else `"latest"`. + */ +const resolveDefaultTag = async (): Promise => { + const prePath = join(REPO_ROOT, '.changeset', 'pre.json'); + if (!(await Bun.file(prePath).exists())) { + return 'latest'; + } + const pre = await readJson<{ mode?: string; tag?: string }>(prePath); + if (pre.mode !== 'pre') { + return 'latest'; + } + if (typeof pre.tag === 'string' && pre.tag.length > 0) { + return pre.tag; + } + throw new Error( + `${prePath} has mode="pre" but no usable "tag" field. Set --tag explicitly or fix pre.json before publishing.` + ); +}; + +/** + * Expand `workspaces` globs from the root `package.json` into absolute + * directory paths that contain a `package.json`. + * + * Handles the simple `dir/*` pattern actually used in this repo plus bare + * `dir` entries. Directory listing is cheaper and more predictable here than + * a full glob implementation. + */ +const discoverWorkspaceDirs = async ( + patterns: readonly string[] +): Promise => { + const dirs: string[] = []; + for (const pattern of patterns) { + if (pattern.endsWith('/*')) { + const parent = join(REPO_ROOT, pattern.slice(0, -2)); + let names: string[] = []; + try { + const entries = await readdir(parent, { withFileTypes: true }); + names = entries + .filter((e) => e.isDirectory()) + .map((e) => (typeof e.name === 'string' ? e.name : String(e.name))); + } catch { + continue; + } + for (const name of names) { + const dir = join(parent, name); + if (await Bun.file(join(dir, 'package.json')).exists()) { + dirs.push(dir); + } + } + } else { + const dir = join(REPO_ROOT, pattern); + if (await Bun.file(join(dir, 'package.json')).exists()) { + dirs.push(dir); + } + } + } + return dirs; +}; + +/** Collect all `workspace:`-referenced dep names from a package.json. */ +const collectWorkspaceDeps = (pkg: NativeBunPublishPackageJson): string[] => { + const deps = new Set(); + for (const field of DEPENDENCY_FIELDS) { + const map = pkg[field]; + if (!map || typeof map !== 'object') { + continue; + } + for (const [name, range] of Object.entries(map)) { + if (typeof range === 'string' && range.startsWith('workspace:')) { + deps.add(name); + } + } + } + return [...deps]; +}; + +const expectedPackedWorkspaceRange = ( + sourceRange: string, + dep: NativeBunPublishWorkspace +): string | undefined => { + if (!sourceRange.startsWith('workspace:')) { + return undefined; + } + const protocolRange = sourceRange.slice('workspace:'.length); + if (protocolRange === '^') { + return `^${dep.version}`; + } + if (protocolRange === '~') { + return `~${dep.version}`; + } + if (protocolRange === '*' || protocolRange === '') { + return dep.version; + } + return protocolRange; +}; + +export const findPackedFirstPartyDependencyMismatches = ({ + packageName, + packagePath, + packedPackage, + sourcePackage, + workspacesByName, +}: { + readonly packageName: string; + readonly packagePath: string; + readonly packedPackage: NativeBunPublishPackageJson; + readonly sourcePackage: NativeBunPublishPackageJson; + readonly workspacesByName: ReadonlyMap; +}): string[] => { + const mismatches: string[] = []; + for (const field of DEPENDENCY_FIELDS) { + const sourceDeps = sourcePackage[field]; + if (!sourceDeps || typeof sourceDeps !== 'object') { + continue; + } + const packedDeps = packedPackage[field] ?? {}; + for (const [depName, sourceRange] of Object.entries(sourceDeps)) { + const dep = workspacesByName.get(depName); + if ( + !dep || + !dep.name.startsWith('@ontrails/') || + typeof sourceRange !== 'string' + ) { + continue; + } + const expected = expectedPackedWorkspaceRange(sourceRange, dep); + if (!expected) { + continue; + } + const actual = packedDeps[depName] ?? '(missing)'; + if (actual !== expected) { + const depPath = relative(REPO_ROOT, dep.path); + mismatches.push( + `${packageName} packed ${field} ${depName} resolved to ${actual}, expected ${expected} from ${depPath}/package.json` + ); + } + } + } + if (mismatches.length === 0) { + return []; + } + const relPath = relative(REPO_ROOT, packagePath); + return [ + `Packed manifest for ${packageName} (${relPath}) contains stale first-party workspace dependency ranges:`, + ...mismatches.map((mismatch) => ` ${mismatch}`), + ]; +}; + +/** Discover all workspace packages and enrich with dep edges. */ +const discoverWorkspaces = async (): Promise => { + const root = await readJson<{ workspaces?: string[] }>( + join(REPO_ROOT, 'package.json') + ); + if (!root.workspaces || root.workspaces.length === 0) { + throw new Error('Root package.json has no "workspaces" field'); + } + const dirs = await discoverWorkspaceDirs(root.workspaces); + + const workspaces: NativeBunPublishWorkspace[] = []; + for (const dir of dirs) { + const pkg = await readJson( + join(dir, 'package.json') + ); + if (!pkg.name) { + continue; + } + workspaces.push({ + isPrivate: pkg.private === true, + name: pkg.name, + path: dir, + version: pkg.version ?? '0.0.0', + workspaceDeps: collectWorkspaceDeps(pkg), + }); + } + return workspaces; +}; + +/** + * Topologically sort workspaces so dependencies come before dependents. + * Ties broken alphabetically by name for deterministic output. Throws on cycles. + */ +const topoSort = ( + workspaces: readonly NativeBunPublishWorkspace[] +): NativeBunPublishWorkspace[] => { + const byName = new Map(workspaces.map((w) => [w.name, w] as const)); + const indegree = new Map(); + const reverseEdges = new Map>(); + + for (const w of workspaces) { + indegree.set(w.name, 0); + reverseEdges.set(w.name, new Set()); + } + for (const w of workspaces) { + for (const dep of w.workspaceDeps) { + if (!byName.has(dep)) { + continue; + } + indegree.set(w.name, (indegree.get(w.name) ?? 0) + 1); + reverseEdges.get(dep)?.add(w.name); + } + } + + const ready: string[] = sortAlpha( + [...indegree.entries()].filter(([, n]) => n === 0).map(([name]) => name) + ); + + const out: NativeBunPublishWorkspace[] = []; + while (ready.length > 0) { + const name = ready.shift() as string; + const ws = byName.get(name); + if (ws) { + out.push(ws); + } + const dependents = sortAlpha([...(reverseEdges.get(name) ?? [])]); + for (const dep of dependents) { + const next = (indegree.get(dep) ?? 0) - 1; + indegree.set(dep, next); + if (next === 0) { + const idx = ready.findIndex((n) => n.localeCompare(dep) > 0); + if (idx === -1) { + ready.push(dep); + } else { + ready.splice(idx, 0, dep); + } + } + } + } + + if (out.length !== workspaces.length) { + const remaining = workspaces + .filter((w) => !out.includes(w)) + .map((w) => w.name); + throw new Error( + `Dependency cycle detected among: ${remaining.toSorted().join(', ')}` + ); + } + return out; +}; + +/** Spawn a child process inheriting stdio. Returns its exit code. */ +const spawnInherit = async ( + cmd: readonly string[], + cwd: string +): Promise => { + const proc = Bun.spawn(cmd as string[], { + cwd, + stderr: 'inherit', + stdin: 'inherit', + stdout: 'inherit', + }); + return await proc.exited; +}; + +/** Spawn a child process and capture stdout (stderr inherited). */ +const spawnCapture = async ( + cmd: readonly string[], + cwd: string +): Promise<{ exitCode: number; stdout: string }> => { + const proc = Bun.spawn(cmd as string[], { + cwd, + stderr: 'inherit', + stdin: 'ignore', + stdout: 'pipe', + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + return { exitCode, stdout }; +}; + +/** + * Pack a package to a temp dir and assert the resulting tarball's + * `package/package.json` contains no `workspace:` or `catalog:` ranges. + * + * @throws When packing fails or forbidden ranges are found. + */ +const assertManifestClean = async ( + ws: NativeBunPublishWorkspace, + workspacesByName: ReadonlyMap +): Promise => { + const tmp = await mkdtemp(join(tmpdir(), 'trails-publish-')); + try { + // Use `bun pm pack` so the packed manifest reflects what `bun publish` + // will upload: workspace: and catalog: ranges are resolved the same way. + // npm pack does not resolve `catalog:` and would produce false positives. + const pack = await spawnCapture( + ['bun', 'pm', 'pack', '--destination', tmp], + ws.path + ); + if (pack.exitCode !== 0) { + throw new Error( + `bun pm pack failed for ${ws.name} (exit ${pack.exitCode})` + ); + } + const tarEntries = await readdir(tmp); + const tarName = tarEntries.find((n) => + typeof n === 'string' ? n.endsWith('.tgz') : String(n).endsWith('.tgz') + ); + if (!tarName) { + throw new Error(`bun pm pack produced no tarball for ${ws.name}`); + } + const tarPath = join(tmp, String(tarName)); + + const extract = Bun.spawn( + ['tar', '-xOf', tarPath, 'package/package.json'], + { stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' } + ); + const [manifestText, tarStderr, extractExit] = await Promise.all([ + new Response(extract.stdout).text(), + new Response(extract.stderr).text(), + extract.exited, + ]); + if (extractExit !== 0) { + const detail = tarStderr.trim() || '(no stderr output)'; + throw new Error( + `tar extraction failed for ${ws.name} (exit ${extractExit}): ${detail}` + ); + } + + let packedPackage: NativeBunPublishPackageJson; + try { + packedPackage = JSON.parse(manifestText) as NativeBunPublishPackageJson; + } catch (error) { + throw new Error( + `Invalid packed package.json for ${ws.name}: ${(error as Error).message}`, + { cause: error } + ); + } + + const offenders: string[] = []; + for (const [lineNo, line] of manifestText.split('\n').entries()) { + if (line.includes('"workspace:') || line.includes('"catalog:')) { + offenders.push(` line ${lineNo + 1}: ${line.trim()}`); + } + } + if (offenders.length > 0) { + const relPath = relative(REPO_ROOT, ws.path); + const hint = + ' Hint: `bun publish` rewrites these at pack time. Verify the package was packed via bun, not npm.'; + throw new Error( + `Packed manifest for ${ws.name} (${relPath}) contains forbidden ranges:\n${offenders.join('\n')}\n${hint}` + ); + } + const sourcePackage = await readJson( + join(ws.path, 'package.json') + ); + const mismatches = findPackedFirstPartyDependencyMismatches({ + packageName: ws.name, + packagePath: ws.path, + packedPackage, + sourcePackage, + workspacesByName, + }); + if (mismatches.length > 0) { + throw new Error(mismatches.join('\n')); + } + } finally { + await rm(tmp, { force: true, recursive: true }); + } +}; + +/** Run `--check` flow: pack dry-run plus manifest-range assertion per package. */ +const runCheck = async ( + workspaces: readonly NativeBunPublishWorkspace[], + allWorkspaces: readonly NativeBunPublishWorkspace[] +): Promise => { + const workspacesByName = new Map(allWorkspaces.map((ws) => [ws.name, ws])); + for (const ws of workspaces) { + if (ws.isPrivate) { + info(`Skipping ${ws.name} (private)`); + continue; + } + info(`Checking ${ws.name}@${ws.version}...`); + const dryRun = await spawnInherit( + ['bun', 'pm', 'pack', '--dry-run'], + ws.path + ); + if (dryRun !== 0) { + fail(`bun pm pack --dry-run failed for ${ws.name}`); + return 1; + } + try { + await assertManifestClean(ws, workspacesByName); + } catch (error) { + fail((error as Error).message); + return 1; + } + success(`${ws.name}@${ws.version} pack check passed`); + } + console.log(''); + success('All package pack checks passed!'); + return 0; +}; + +/** Run the actual publish flow sequentially. Aborts on first failure. */ +const runPublish = async ( + workspaces: readonly NativeBunPublishWorkspace[], + tag: string, + otp: string | undefined +): Promise => { + for (const ws of workspaces) { + if (ws.isPrivate) { + info(`Skipping ${ws.name} (private)`); + continue; + } + info(`Publishing ${ws.name}@${ws.version}... (tag=${tag})`); + const cmd: string[] = [ + 'bun', + 'publish', + '--access', + 'public', + '--tag', + tag, + ]; + if (otp) { + cmd.push('--otp', otp); + } + const code = await spawnInherit(cmd, ws.path); + if (code !== 0) { + fail( + `Failed to publish ${ws.name} (exit ${code}); aborting remaining publishes` + ); + return 1; + } + success(`${ws.name}@${ws.version} published`); + } + console.log(''); + success('All packages published!'); + return 0; +}; + +/** Apply `--only` filter, erroring if any requested name is unknown. */ +const applyOnlyFilter = ( + workspaces: readonly NativeBunPublishWorkspace[], + only: readonly string[] | undefined +): readonly NativeBunPublishWorkspace[] => { + if (!only) { + return workspaces; + } + const set = new Set(only); + const names = new Set(workspaces.map((w) => w.name)); + const unknown = [...set].filter((n) => !names.has(n)); + if (unknown.length > 0) { + fail(`--only references unknown packages: ${unknown.join(', ')}`); + process.exit(2); + } + return workspaces.filter((w) => set.has(w.name)); +}; + +export const runNativeBunPublishCli = async ( + args: readonly string[] = process.argv.slice(2) +): Promise => { + try { + const opts = parseArgs(args); + const all = await discoverWorkspaces(); + const sorted = topoSort(all); + const selected = applyOnlyFilter(sorted, opts.only); + + if (selected.length === 0) { + fail('No workspaces selected'); + return 1; + } + + if (opts.mode === 'check') { + return await runCheck(selected, all); + } + + const tag = opts.tag ?? (await resolveDefaultTag()); + if (!tag) { + fail('Could not resolve a dist-tag. Pass --tag explicitly.'); + return 1; + } + return await runPublish(selected, tag, opts.otp); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + fail(msg); + if (process.env['DEBUG'] === '1' && error instanceof Error && error.stack) { + console.error(error.stack); + } + return 1; + } +}; + +if (import.meta.main) { + process.exit(await runNativeBunPublishCli(process.argv.slice(2))); +} diff --git a/apps/trails/src/release/native-bun-registry.ts b/apps/trails/src/release/native-bun-registry.ts new file mode 100644 index 000000000..1c3880b54 --- /dev/null +++ b/apps/trails/src/release/native-bun-registry.ts @@ -0,0 +1,350 @@ +/* oxlint-disable max-statements -- release preflight CLI with explicit reporting */ +import { readdir } from 'node:fs/promises'; +import { join, relative, resolve } from 'node:path'; + +const REPO_ROOT = resolve(process.cwd()); +const SUMMARY_DIST_TAGS = ['latest', 'beta'] as const; + +export interface RegistryPreflightOptions { + readonly requirePublished: boolean; + readonly tag: string | undefined; +} + +interface PackageJson { + readonly name?: string; + readonly private?: boolean; + readonly version?: string; +} + +export interface RegistryWorkspace { + readonly name: string; + readonly path: string; + readonly version: string; +} + +interface NpmView { + readonly name?: string; + readonly version?: string; + readonly 'dist-tags'?: Record; +} + +export type RegistryResult = + | { + readonly distTags: Record; + readonly expectedTagVersion: string | undefined; + readonly name: string; + readonly status: 'published'; + readonly version: string; + readonly workspaceVersion: string; + } + | { + readonly name: string; + readonly status: 'missing'; + readonly workspaceVersion: string; + } + | { + readonly error: string; + readonly name: string; + readonly status: 'inaccessible'; + readonly workspaceVersion: string; + }; + +const USAGE = `Usage: bun scripts/check-registry-preflight.ts [options] + +Read-only npm registry preflight for public @ontrails/* workspaces. + +Options: + --tag Expected npm dist-tag. Defaults to .changeset/pre.json + tag while in prerelease mode, otherwise "latest". + --require-published Fail when any workspace package is missing from npm. + Use after publication to verify every package exists. + -h, --help Show this help and exit. + +Exit codes: 0 success, 1 registry posture failure, 2 arg-parse error.`; + +const parseArgs = (argv: readonly string[]): RegistryPreflightOptions => { + let requirePublished = false; + let tag: string | undefined; + + const needsValue = (flag: string, value: string | undefined): string => { + if (value === undefined || value.startsWith('--')) { + console.error(`${flag} requires a value`); + console.error(USAGE); + process.exit(2); + } + return value; + }; + + let i = 0; + while (i < argv.length) { + const arg = argv[i] as string; + if (arg === '--require-published') { + requirePublished = true; + } else if (arg === '--tag') { + i += 1; + tag = needsValue('--tag', argv[i]); + } else if (arg === '-h' || arg === '--help') { + console.log(USAGE); + process.exit(0); + } else { + console.error(`Unknown argument: ${arg}`); + console.error(USAGE); + process.exit(2); + } + i += 1; + } + + return { requirePublished, tag }; +}; + +const readJson = async (path: string): Promise => { + const file = Bun.file(path); + if (!(await file.exists())) { + throw new Error(`File not found: ${path}`); + } + return (await file.json()) as T; +}; + +const errorCode = (error: unknown): string | undefined => { + if (typeof error !== 'object' || error === null || !('code' in error)) { + return undefined; + } + const { code } = error as { readonly code?: unknown }; + return typeof code === 'string' ? code : undefined; +}; + +const resolveDefaultTag = async (): Promise => { + const prePath = join(REPO_ROOT, '.changeset', 'pre.json'); + if (!(await Bun.file(prePath).exists())) { + return 'latest'; + } + const pre = await readJson<{ mode?: string; tag?: string }>(prePath); + if (pre.mode !== 'pre') { + return 'latest'; + } + if (typeof pre.tag === 'string' && pre.tag.length > 0) { + return pre.tag; + } + throw new Error(`${prePath} is in prerelease mode but has no tag`); +}; + +const discoverWorkspaceDirs = async ( + repoRoot: string, + patterns: readonly string[] +): Promise => { + const dirs: string[] = []; + for (const pattern of patterns) { + if (pattern.endsWith('/*')) { + const parent = join(repoRoot, pattern.slice(0, -2)); + let names: string[] = []; + try { + const entries = await readdir(parent, { withFileTypes: true }); + names = entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch (error) { + if (errorCode(error) === 'ENOENT') { + continue; + } + throw new Error( + `Unable to read workspace directory ${relative(repoRoot, parent)}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ); + } + for (const name of names) { + const dir = join(parent, name); + if (await Bun.file(join(dir, 'package.json')).exists()) { + dirs.push(dir); + } + } + } else { + const dir = join(repoRoot, pattern); + if (await Bun.file(join(dir, 'package.json')).exists()) { + dirs.push(dir); + } + } + } + return dirs; +}; + +export const discoverRegistryWorkspaces = async ( + repoRoot = REPO_ROOT +): Promise => { + const root = await readJson<{ workspaces?: string[] }>( + join(repoRoot, 'package.json') + ); + const dirs = await discoverWorkspaceDirs(repoRoot, root.workspaces ?? []); + const workspaces: RegistryWorkspace[] = []; + + for (const dir of dirs) { + const pkg = await readJson(join(dir, 'package.json')); + if ( + pkg.private === true || + typeof pkg.name !== 'string' || + !pkg.name.startsWith('@ontrails/') || + typeof pkg.version !== 'string' + ) { + continue; + } + workspaces.push({ + name: pkg.name, + path: relative(repoRoot, dir), + version: pkg.version, + }); + } + + return workspaces.toSorted((a, b) => a.name.localeCompare(b.name)); +}; + +export type RegistryView = (name: string) => Promise; + +export const npmRegistryView: RegistryView = async (name) => { + const proc = Bun.spawn( + ['npm', 'view', name, 'name', 'version', 'dist-tags', '--json'], + { stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' } + ); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode === 0) { + return JSON.parse(stdout) as NpmView; + } + const combined = `${stdout}\n${stderr}`; + if (combined.includes('E404') || combined.includes('404 Not Found')) { + return null; + } + throw new Error(stderr.trim() || `npm view failed for ${name}`); +}; + +const checkWorkspaceRegistryPosture = async ( + workspace: RegistryWorkspace, + view: RegistryView, + expectedTag: string +): Promise => { + try { + const registry = await view(workspace.name); + if (!registry) { + return { + name: workspace.name, + status: 'missing', + workspaceVersion: workspace.version, + }; + } + const distTags = registry['dist-tags'] ?? {}; + return { + distTags, + expectedTagVersion: distTags[expectedTag], + name: workspace.name, + status: 'published', + version: registry.version ?? '(unknown)', + workspaceVersion: workspace.version, + }; + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + name: workspace.name, + status: 'inaccessible', + workspaceVersion: workspace.version, + }; + } +}; + +export const checkRegistryPosture = async ( + workspaces: readonly RegistryWorkspace[], + view: RegistryView, + expectedTag: string +): Promise => + Promise.all( + workspaces.map((workspace) => + checkWorkspaceRegistryPosture(workspace, view, expectedTag) + ) + ); + +export const registryPostureErrors = ( + results: readonly RegistryResult[], + expectedTag: string, + requirePublished: boolean +): string[] => { + const errors: string[] = []; + for (const result of results) { + if (result.status === 'inaccessible') { + errors.push(`${result.name}: registry probe failed: ${result.error}`); + } else if (result.status === 'missing') { + if (requirePublished) { + errors.push(`${result.name}: package is missing from the registry`); + } + } else if (result.expectedTagVersion !== result.workspaceVersion) { + errors.push( + `${result.name}: dist-tag ${expectedTag} points to ${result.expectedTagVersion ?? '(missing)'}, expected ${result.workspaceVersion}` + ); + } + } + return errors; +}; + +export const formatDistTagSummary = ( + distTags: Readonly> +): string => + SUMMARY_DIST_TAGS.map((tag) => `${tag}=${distTags[tag] ?? 'missing'}`).join( + ', ' + ); + +const printResults = ( + results: readonly RegistryResult[], + expectedTag: string +): void => { + console.log(`Registry preflight for dist-tag "${expectedTag}"`); + for (const result of results) { + if (result.status === 'published') { + console.log( + `✓ ${result.name}@${result.workspaceVersion}: published (registry version ${result.version}, expected ${expectedTag}=${result.expectedTagVersion ?? 'missing'}, tags ${formatDistTagSummary(result.distTags)})` + ); + } else if (result.status === 'missing') { + console.log( + `• ${result.name}@${result.workspaceVersion}: first-time package candidate (not found on registry)` + ); + } else { + console.log(`✗ ${result.name}: registry probe failed: ${result.error}`); + } + } +}; + +export const runRegistryPreflight = async ( + options: RegistryPreflightOptions, + view: RegistryView = npmRegistryView +): Promise => { + const expectedTag = options.tag ?? (await resolveDefaultTag()); + const workspaces = await discoverRegistryWorkspaces(); + const results = await checkRegistryPosture(workspaces, view, expectedTag); + printResults(results, expectedTag); + const errors = registryPostureErrors( + results, + expectedTag, + options.requirePublished + ); + if (errors.length > 0) { + console.error('\nRegistry preflight failed:'); + for (const error of errors) { + console.error(`- ${error}`); + } + return 1; + } + console.log('\nRegistry preflight passed.'); + return 0; +}; + +export const runRegistryPreflightCli = async ( + args: readonly string[] = process.argv.slice(2) +): Promise => { + try { + return await runRegistryPreflight(parseArgs(args)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +}; + +if (import.meta.main) { + process.exit(await runRegistryPreflightCli(process.argv.slice(2))); +} diff --git a/docs/contributing/script-graduation.md b/docs/contributing/script-graduation.md index a83c1e58f..b3f82e3f8 100644 --- a/docs/contributing/script-graduation.md +++ b/docs/contributing/script-graduation.md @@ -62,6 +62,8 @@ Keep three axes distinct: native vs adapter is the *kind*; subpath/built-in vs e `release.check` is the derives-and-graduates example: release rules are durable Trails-contract facts, so the script-era checker graduated into the `trails release check` surface, and package scripts became thin callers. `vocab-cutover-rewrite` is the derives-but-stays-tooling example: real derivation over a transient truth. +The native Bun release binding is the fills-a-declared-seam example: `@ontrails/trails/release` owns the binding descriptor plus Bun pack, publish, and registry preflight implementation. Root `publish:*` scripts are compatibility wrappers around that binding. They remain useful named exits, but they do not own the release behavior. + ## After graduation Scripts may remain as compatibility wrappers or thin callers after their logic graduates — CI entry points and `bun run` ergonomics are reasons to keep a named exit. Not every script becomes a public CLI command: repo-local Warden rules, internal commands, native bindings, adapter bindings, and plain tooling are all valid homes. The test is who owns the derivation, not whether a file still exists under `scripts/`. diff --git a/docs/releases/beta-channel-policy.md b/docs/releases/beta-channel-policy.md index 9fd5d18f9..23e1a5dc1 100644 --- a/docs/releases/beta-channel-policy.md +++ b/docs/releases/beta-channel-policy.md @@ -41,7 +41,7 @@ For fully reproducible docs, replace `@beta` with the exact beta version named b `.changeset/pre.json` is the channel source while it has `mode: "pre"`. The current prerelease tag is `beta`. -The release scripts follow that source: +The native Bun release binding follows that source. The package scripts below are compatibility wrappers around the binding: - `bun run publish:check` is local and read-only. - `bun run publish:registry-check` defaults to `.changeset/pre.json`'s tag in @@ -55,6 +55,10 @@ During the beta line, `latest` may intentionally lag behind `beta`. Operators sh Do not use `npm publish`, `changeset publish`, or ad hoc dist-tag mutation for normal Trails package releases. +## Native Bun Release Binding + +`@ontrails/trails/release` declares the built-in native Bun release binding. It owns package discovery, workspace dependency ordering, `bun pm pack` verification, `bun publish` invocation, and read-only npm registry preflight. The binding is native because it is Trails-owned, same-package, and uses the ambient Bun runtime. Future integrations that cross into a foreign release tool or registry contract belong in adapter bindings instead. + ## Read-Only Registry Checks The standard beta posture check is: diff --git a/scripts/check-registry-preflight.ts b/scripts/check-registry-preflight.ts index 6341871f6..9a14cf953 100644 --- a/scripts/check-registry-preflight.ts +++ b/scripts/check-registry-preflight.ts @@ -1,345 +1,16 @@ #!/usr/bin/env bun -/* oxlint-disable max-statements -- release preflight CLI with explicit reporting */ -import { readdir } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; - -const REPO_ROOT = resolve(import.meta.dir, '..'); -const SUMMARY_DIST_TAGS = ['latest', 'beta'] as const; - -interface Options { - readonly requirePublished: boolean; - readonly tag: string | undefined; -} - -interface PackageJson { - readonly name?: string; - readonly private?: boolean; - readonly version?: string; -} - -export interface RegistryWorkspace { - readonly name: string; - readonly path: string; - readonly version: string; -} - -interface NpmView { - readonly name?: string; - readonly version?: string; - readonly 'dist-tags'?: Record; -} - -export type RegistryResult = - | { - readonly distTags: Record; - readonly expectedTagVersion: string | undefined; - readonly name: string; - readonly status: 'published'; - readonly version: string; - readonly workspaceVersion: string; - } - | { - readonly name: string; - readonly status: 'missing'; - readonly workspaceVersion: string; - } - | { - readonly error: string; - readonly name: string; - readonly status: 'inaccessible'; - readonly workspaceVersion: string; - }; - -const USAGE = `Usage: bun scripts/check-registry-preflight.ts [options] - -Read-only npm registry preflight for public @ontrails/* workspaces. - -Options: - --tag Expected npm dist-tag. Defaults to .changeset/pre.json - tag while in prerelease mode, otherwise "latest". - --require-published Fail when any workspace package is missing from npm. - Use after publication to verify every package exists. - -h, --help Show this help and exit. - -Exit codes: 0 success, 1 registry posture failure, 2 arg-parse error.`; - -const parseArgs = (argv: readonly string[]): Options => { - let requirePublished = false; - let tag: string | undefined; - - const needsValue = (flag: string, value: string | undefined): string => { - if (value === undefined || value.startsWith('--')) { - console.error(`${flag} requires a value`); - console.error(USAGE); - process.exit(2); - } - return value; - }; - - let i = 0; - while (i < argv.length) { - const arg = argv[i] as string; - if (arg === '--require-published') { - requirePublished = true; - } else if (arg === '--tag') { - i += 1; - tag = needsValue('--tag', argv[i]); - } else if (arg === '-h' || arg === '--help') { - console.log(USAGE); - process.exit(0); - } else { - console.error(`Unknown argument: ${arg}`); - console.error(USAGE); - process.exit(2); - } - i += 1; - } - - return { requirePublished, tag }; -}; - -const readJson = async (path: string): Promise => { - const file = Bun.file(path); - if (!(await file.exists())) { - throw new Error(`File not found: ${path}`); - } - return (await file.json()) as T; -}; - -const errorCode = (error: unknown): string | undefined => { - if (typeof error !== 'object' || error === null || !('code' in error)) { - return undefined; - } - const { code } = error as { readonly code?: unknown }; - return typeof code === 'string' ? code : undefined; -}; - -const resolveDefaultTag = async (): Promise => { - const prePath = join(REPO_ROOT, '.changeset', 'pre.json'); - if (!(await Bun.file(prePath).exists())) { - return 'latest'; - } - const pre = await readJson<{ mode?: string; tag?: string }>(prePath); - if (pre.mode !== 'pre') { - return 'latest'; - } - if (typeof pre.tag === 'string' && pre.tag.length > 0) { - return pre.tag; - } - throw new Error(`${prePath} is in prerelease mode but has no tag`); -}; - -const discoverWorkspaceDirs = async ( - repoRoot: string, - patterns: readonly string[] -): Promise => { - const dirs: string[] = []; - for (const pattern of patterns) { - if (pattern.endsWith('/*')) { - const parent = join(repoRoot, pattern.slice(0, -2)); - let names: string[] = []; - try { - const entries = await readdir(parent, { withFileTypes: true }); - names = entries.filter((e) => e.isDirectory()).map((e) => e.name); - } catch (error) { - if (errorCode(error) === 'ENOENT') { - continue; - } - throw new Error( - `Unable to read workspace directory ${relative(repoRoot, parent)}: ${error instanceof Error ? error.message : String(error)}`, - { cause: error } - ); - } - for (const name of names) { - const dir = join(parent, name); - if (await Bun.file(join(dir, 'package.json')).exists()) { - dirs.push(dir); - } - } - } else { - const dir = join(repoRoot, pattern); - if (await Bun.file(join(dir, 'package.json')).exists()) { - dirs.push(dir); - } - } - } - return dirs; -}; - -export const discoverRegistryWorkspaces = async ( - repoRoot = REPO_ROOT -): Promise => { - const root = await readJson<{ workspaces?: string[] }>( - join(repoRoot, 'package.json') - ); - const dirs = await discoverWorkspaceDirs(repoRoot, root.workspaces ?? []); - const workspaces: RegistryWorkspace[] = []; - - for (const dir of dirs) { - const pkg = await readJson(join(dir, 'package.json')); - if ( - pkg.private === true || - typeof pkg.name !== 'string' || - !pkg.name.startsWith('@ontrails/') || - typeof pkg.version !== 'string' - ) { - continue; - } - workspaces.push({ - name: pkg.name, - path: relative(repoRoot, dir), - version: pkg.version, - }); - } - - return workspaces.toSorted((a, b) => a.name.localeCompare(b.name)); -}; - -export type RegistryView = (name: string) => Promise; - -export const npmRegistryView: RegistryView = async (name) => { - const proc = Bun.spawn( - ['npm', 'view', name, 'name', 'version', 'dist-tags', '--json'], - { stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' } - ); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]); - - if (exitCode === 0) { - return JSON.parse(stdout) as NpmView; - } - const combined = `${stdout}\n${stderr}`; - if (combined.includes('E404') || combined.includes('404 Not Found')) { - return null; - } - throw new Error(stderr.trim() || `npm view failed for ${name}`); -}; - -const checkWorkspaceRegistryPosture = async ( - workspace: RegistryWorkspace, - view: RegistryView, - expectedTag: string -): Promise => { - try { - const registry = await view(workspace.name); - if (!registry) { - return { - name: workspace.name, - status: 'missing', - workspaceVersion: workspace.version, - }; - } - const distTags = registry['dist-tags'] ?? {}; - return { - distTags, - expectedTagVersion: distTags[expectedTag], - name: workspace.name, - status: 'published', - version: registry.version ?? '(unknown)', - workspaceVersion: workspace.version, - }; - } catch (error) { - return { - error: error instanceof Error ? error.message : String(error), - name: workspace.name, - status: 'inaccessible', - workspaceVersion: workspace.version, - }; - } -}; - -export const checkRegistryPosture = async ( - workspaces: readonly RegistryWorkspace[], - view: RegistryView, - expectedTag: string -): Promise => - Promise.all( - workspaces.map((workspace) => - checkWorkspaceRegistryPosture(workspace, view, expectedTag) - ) - ); - -export const registryPostureErrors = ( - results: readonly RegistryResult[], - expectedTag: string, - requirePublished: boolean -): string[] => { - const errors: string[] = []; - for (const result of results) { - if (result.status === 'inaccessible') { - errors.push(`${result.name}: registry probe failed: ${result.error}`); - } else if (result.status === 'missing') { - if (requirePublished) { - errors.push(`${result.name}: package is missing from the registry`); - } - } else if (result.expectedTagVersion !== result.workspaceVersion) { - errors.push( - `${result.name}: dist-tag ${expectedTag} points to ${result.expectedTagVersion ?? '(missing)'}, expected ${result.workspaceVersion}` - ); - } - } - return errors; -}; - -export const formatDistTagSummary = ( - distTags: Readonly> -): string => - SUMMARY_DIST_TAGS.map((tag) => `${tag}=${distTags[tag] ?? 'missing'}`).join( - ', ' - ); - -const printResults = ( - results: readonly RegistryResult[], - expectedTag: string -): void => { - console.log(`Registry preflight for dist-tag "${expectedTag}"`); - for (const result of results) { - if (result.status === 'published') { - console.log( - `✓ ${result.name}@${result.workspaceVersion}: published (registry version ${result.version}, expected ${expectedTag}=${result.expectedTagVersion ?? 'missing'}, tags ${formatDistTagSummary(result.distTags)})` - ); - } else if (result.status === 'missing') { - console.log( - `• ${result.name}@${result.workspaceVersion}: first-time package candidate (not found on registry)` - ); - } else { - console.log(`✗ ${result.name}: registry probe failed: ${result.error}`); - } - } -}; - -export const runRegistryPreflight = async ( - options: Options, - view: RegistryView = npmRegistryView -): Promise => { - const expectedTag = options.tag ?? (await resolveDefaultTag()); - const workspaces = await discoverRegistryWorkspaces(); - const results = await checkRegistryPosture(workspaces, view, expectedTag); - printResults(results, expectedTag); - const errors = registryPostureErrors( - results, - expectedTag, - options.requirePublished - ); - if (errors.length > 0) { - console.error('\nRegistry preflight failed:'); - for (const error of errors) { - console.error(`- ${error}`); - } - return 1; - } - console.log('\nRegistry preflight passed.'); - return 0; -}; +import { runRegistryPreflightCli } from '@ontrails/trails/release'; if (import.meta.main) { - try { - process.exit(await runRegistryPreflight(parseArgs(process.argv.slice(2)))); - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - process.exit(1); - } + process.exit(await runRegistryPreflightCli(process.argv.slice(2))); } + +export { + checkRegistryPosture, + discoverRegistryWorkspaces, + formatDistTagSummary, + registryPostureErrors, + runRegistryPreflight, + type RegistryView, + type RegistryWorkspace, +} from '@ontrails/trails/release'; diff --git a/scripts/publish.ts b/scripts/publish.ts index 850f555be..e989a1076 100644 --- a/scripts/publish.ts +++ b/scripts/publish.ts @@ -1,647 +1,8 @@ #!/usr/bin/env bun -/* oxlint-disable eslint-plugin-jest/require-hook, max-statements, func-style -- release script with module-level flow */ -/** - * Publish all public `@ontrails/*` workspaces in dep order using `bun publish`. - * - * Auto-discovers workspaces from the root `package.json` `workspaces` field, - * topo-sorts them by `workspace:` dependency edges, enforces manifest-range - * cleanliness on the packed tarball (no `workspace:` / `catalog:` leakage), - * and respects the Changesets prerelease tag by default. - * - * @see docs/tenets.md for release posture. - */ - -import { mkdtemp, readdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, relative, resolve } from 'node:path'; - -const REPO_ROOT = resolve(import.meta.dir, '..'); - -/** ANSI color helpers, disabled when stdout is not a TTY or `NO_COLOR` is set. */ -const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; -const color = (code: string, text: string): string => - useColor ? `\u001B[${code}m${text}\u001B[0m` : text; -const blue = (t: string) => color('0;34', t); -const green = (t: string) => color('0;32', t); -const red = (t: string) => color('0;31', t); - -const info = (msg: string) => console.log(`${blue('▸')} ${msg}`); -const success = (msg: string) => console.log(`${green('✓')} ${msg}`); -const fail = (msg: string) => console.error(`${red('✗')} ${msg}`); - -/** Parsed CLI options. */ -interface Options { - readonly mode: 'check' | 'publish'; - readonly tag: string | undefined; - readonly otp: string | undefined; - readonly only: readonly string[] | undefined; -} - -/** Minimal shape of a workspace `package.json` we care about. */ -interface PackageJson { - name?: string; - version?: string; - private?: boolean; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; -} - -type DependencyField = - | 'dependencies' - | 'devDependencies' - | 'peerDependencies' - | 'optionalDependencies'; - -/** A discovered, publishable workspace. */ -interface Workspace { - readonly name: string; - readonly version: string; - readonly path: string; - readonly isPrivate: boolean; - readonly workspaceDeps: readonly string[]; -} - -const DEPENDENCY_FIELDS: readonly DependencyField[] = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'optionalDependencies', -]; - -const USAGE = `Usage: bun scripts/publish.ts [options] - -Publish all public @ontrails/* workspaces in dep order using \`bun publish\`. - -Options: - --check Pre-publish verification only. Runs \`bun pm pack --dry-run\` - (required so \`catalog:\` resolves) and asserts the packed - manifest has no \`workspace:\` or \`catalog:\` ranges. No publishing. - --dry-run Alias for --check. - --tag npm dist-tag. Defaults to .changeset/pre.json tag when in - prerelease mode, otherwise "latest". - --otp Two-factor code. Also read from BUN_PUBLISH_OTP. - --only Restrict to the named packages (repeatable). Useful for - partial reruns after a mid-matrix failure. - -h, --help Show this help and exit. - -Exit codes: 0 success, 1 publish/check failure, 2 arg-parse error.`; - -/** Alphabetical sort helper, hoisted for reuse. */ -const sortAlpha = (names: string[]): string[] => - names.sort((a, b) => a.localeCompare(b)); - -/** Parse CLI args with a tiny hand-rolled parser. Exits with code 2 on error. */ -const parseArgs = (argv: readonly string[]): Options => { - let mode: Options['mode'] = 'publish'; - let tag: string | undefined; - let otp: string | undefined = process.env.BUN_PUBLISH_OTP || undefined; - const only: string[] = []; - - const needsValue = (flag: string, value: string | undefined): string => { - if (value === undefined || value.startsWith('--')) { - fail(`${flag} requires a value`); - console.error(USAGE); - process.exit(2); - } - return value; - }; - - let i = 0; - while (i < argv.length) { - const arg = argv[i] as string; - if (arg === '--check' || arg === '--dry-run') { - mode = 'check'; - } else if (arg === '--tag') { - i += 1; - tag = needsValue('--tag', argv[i]); - } else if (arg === '--otp') { - i += 1; - otp = needsValue('--otp', argv[i]); - } else if (arg === '--only') { - i += 1; - const value = needsValue('--only', argv[i]); - for (const name of value - .split(',') - .map((s) => s.trim()) - .filter(Boolean)) { - only.push(name); - } - } else if (arg === '-h' || arg === '--help') { - console.log(USAGE); - process.exit(0); - } else { - fail(`Unknown argument: ${arg}`); - console.error(USAGE); - process.exit(2); - } - i += 1; - } - - return { - mode, - only: only.length > 0 ? only : undefined, - otp, - tag, - }; -}; - -/** Read and JSON-parse a file. Throws a readable error on failure. */ -const readJson = async (path: string): Promise => { - const file = Bun.file(path); - if (!(await file.exists())) { - throw new Error(`File not found: ${path}`); - } - const text = await file.text(); - try { - return JSON.parse(text) as T; - } catch (error) { - throw new Error(`Invalid JSON in ${path}: ${(error as Error).message}`, { - cause: error, - }); - } -}; - -/** - * Resolve the default dist-tag by inspecting `.changeset/pre.json`. - * Returns the prerelease tag when Changesets is in `pre` mode, else `"latest"`. - */ -const resolveDefaultTag = async (): Promise => { - const prePath = join(REPO_ROOT, '.changeset', 'pre.json'); - if (!(await Bun.file(prePath).exists())) { - return 'latest'; - } - const pre = await readJson<{ mode?: string; tag?: string }>(prePath); - if (pre.mode !== 'pre') { - return 'latest'; - } - if (typeof pre.tag === 'string' && pre.tag.length > 0) { - return pre.tag; - } - throw new Error( - `${prePath} has mode="pre" but no usable "tag" field — set --tag explicitly or fix pre.json before publishing (silent fallback to "latest" would mistag a prerelease as the stable channel).` - ); -}; - -/** - * Expand `workspaces` globs from the root `package.json` into absolute - * directory paths that contain a `package.json`. - * - * Handles the simple `dir/*` pattern actually used in this repo plus bare - * `dir` entries. Directory listing is cheaper and more predictable here than - * a full glob implementation. - */ -const discoverWorkspaceDirs = async ( - patterns: readonly string[] -): Promise => { - const dirs: string[] = []; - for (const pattern of patterns) { - if (pattern.endsWith('/*')) { - const parent = join(REPO_ROOT, pattern.slice(0, -2)); - let names: string[] = []; - try { - const entries = await readdir(parent, { withFileTypes: true }); - names = entries - .filter((e) => e.isDirectory()) - .map((e) => (typeof e.name === 'string' ? e.name : String(e.name))); - } catch { - continue; - } - for (const name of names) { - const dir = join(parent, name); - if (await Bun.file(join(dir, 'package.json')).exists()) { - dirs.push(dir); - } - } - } else { - const dir = join(REPO_ROOT, pattern); - if (await Bun.file(join(dir, 'package.json')).exists()) { - dirs.push(dir); - } - } - } - return dirs; -}; - -/** Collect all `workspace:`-referenced dep names from a package.json. */ -const collectWorkspaceDeps = (pkg: PackageJson): string[] => { - const deps = new Set(); - for (const field of DEPENDENCY_FIELDS) { - const map = pkg[field]; - if (!map || typeof map !== 'object') { - continue; - } - for (const [name, range] of Object.entries(map)) { - if (typeof range === 'string' && range.startsWith('workspace:')) { - deps.add(name); - } - } - } - return [...deps]; -}; - -const expectedPackedWorkspaceRange = ( - sourceRange: string, - dep: Workspace -): string | undefined => { - if (!sourceRange.startsWith('workspace:')) { - return undefined; - } - const protocolRange = sourceRange.slice('workspace:'.length); - if (protocolRange === '^') { - return `^${dep.version}`; - } - if (protocolRange === '~') { - return `~${dep.version}`; - } - if (protocolRange === '*' || protocolRange === '') { - return dep.version; - } - return protocolRange; -}; - -export const findPackedFirstPartyDependencyMismatches = ({ - packageName, - packagePath, - packedPackage, - sourcePackage, - workspacesByName, -}: { - readonly packageName: string; - readonly packagePath: string; - readonly packedPackage: PackageJson; - readonly sourcePackage: PackageJson; - readonly workspacesByName: ReadonlyMap; -}): string[] => { - const mismatches: string[] = []; - for (const field of DEPENDENCY_FIELDS) { - const sourceDeps = sourcePackage[field]; - if (!sourceDeps || typeof sourceDeps !== 'object') { - continue; - } - const packedDeps = packedPackage[field] ?? {}; - for (const [depName, sourceRange] of Object.entries(sourceDeps)) { - const dep = workspacesByName.get(depName); - if ( - !dep || - !dep.name.startsWith('@ontrails/') || - typeof sourceRange !== 'string' - ) { - continue; - } - const expected = expectedPackedWorkspaceRange(sourceRange, dep); - if (!expected) { - continue; - } - const actual = packedDeps[depName] ?? '(missing)'; - if (actual !== expected) { - const depPath = relative(REPO_ROOT, dep.path); - mismatches.push( - `${packageName} packed ${field} ${depName} resolved to ${actual}, expected ${expected} from ${depPath}/package.json` - ); - } - } - } - if (mismatches.length === 0) { - return []; - } - const relPath = relative(REPO_ROOT, packagePath); - return [ - `Packed manifest for ${packageName} (${relPath}) contains stale first-party workspace dependency ranges:`, - ...mismatches.map((mismatch) => ` ${mismatch}`), - ]; -}; - -/** Discover all workspace packages and enrich with dep edges. */ -const discoverWorkspaces = async (): Promise => { - const root = await readJson<{ workspaces?: string[] }>( - join(REPO_ROOT, 'package.json') - ); - if (!root.workspaces || root.workspaces.length === 0) { - throw new Error('Root package.json has no "workspaces" field'); - } - const dirs = await discoverWorkspaceDirs(root.workspaces); - - const workspaces: Workspace[] = []; - for (const dir of dirs) { - const pkg = await readJson(join(dir, 'package.json')); - if (!pkg.name) { - continue; - } - workspaces.push({ - isPrivate: pkg.private === true, - name: pkg.name, - path: dir, - version: pkg.version ?? '0.0.0', - workspaceDeps: collectWorkspaceDeps(pkg), - }); - } - return workspaces; -}; - -/** - * Topologically sort workspaces so dependencies come before dependents. - * Ties broken alphabetically by name for deterministic output. Throws on cycles. - */ -const topoSort = (workspaces: readonly Workspace[]): Workspace[] => { - const byName = new Map(workspaces.map((w) => [w.name, w] as const)); - const indegree = new Map(); - const reverseEdges = new Map>(); - - for (const w of workspaces) { - indegree.set(w.name, 0); - reverseEdges.set(w.name, new Set()); - } - for (const w of workspaces) { - for (const dep of w.workspaceDeps) { - if (!byName.has(dep)) { - continue; - } - indegree.set(w.name, (indegree.get(w.name) ?? 0) + 1); - reverseEdges.get(dep)?.add(w.name); - } - } - - const ready: string[] = sortAlpha( - [...indegree.entries()].filter(([, n]) => n === 0).map(([name]) => name) - ); - - const out: Workspace[] = []; - while (ready.length > 0) { - const name = ready.shift() as string; - const ws = byName.get(name); - if (ws) { - out.push(ws); - } - const dependents = sortAlpha([...(reverseEdges.get(name) ?? [])]); - for (const dep of dependents) { - const next = (indegree.get(dep) ?? 0) - 1; - indegree.set(dep, next); - if (next === 0) { - const idx = ready.findIndex((n) => n.localeCompare(dep) > 0); - if (idx === -1) { - ready.push(dep); - } else { - ready.splice(idx, 0, dep); - } - } - } - } - - if (out.length !== workspaces.length) { - const remaining = workspaces - .filter((w) => !out.includes(w)) - .map((w) => w.name); - throw new Error( - `Dependency cycle detected among: ${remaining.toSorted().join(', ')}` - ); - } - return out; -}; - -/** Spawn a child process inheriting stdio. Returns its exit code. */ -const spawnInherit = async ( - cmd: readonly string[], - cwd: string -): Promise => { - const proc = Bun.spawn(cmd as string[], { - cwd, - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }); - return await proc.exited; -}; - -/** Spawn a child process and capture stdout (stderr inherited). */ -const spawnCapture = async ( - cmd: readonly string[], - cwd: string -): Promise<{ exitCode: number; stdout: string }> => { - const proc = Bun.spawn(cmd as string[], { - cwd, - stderr: 'inherit', - stdin: 'ignore', - stdout: 'pipe', - }); - const stdout = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - return { exitCode, stdout }; -}; - -/** - * Pack a package to a temp dir and assert the resulting tarball's - * `package/package.json` contains no `workspace:` or `catalog:` ranges. - * - * @throws When packing fails or forbidden ranges are found. - */ -const assertManifestClean = async ( - ws: Workspace, - workspacesByName: ReadonlyMap -): Promise => { - const tmp = await mkdtemp(join(tmpdir(), 'trails-publish-')); - try { - // Use `bun pm pack` so the packed manifest reflects what `bun publish` - // will upload: workspace: and catalog: ranges are resolved the same way. - // npm pack does not resolve `catalog:` and would produce false positives. - const pack = await spawnCapture( - ['bun', 'pm', 'pack', '--destination', tmp], - ws.path - ); - if (pack.exitCode !== 0) { - throw new Error( - `bun pm pack failed for ${ws.name} (exit ${pack.exitCode})` - ); - } - const tarEntries = await readdir(tmp); - const tarName = tarEntries.find((n) => - typeof n === 'string' ? n.endsWith('.tgz') : String(n).endsWith('.tgz') - ); - if (!tarName) { - throw new Error(`bun pm pack produced no tarball for ${ws.name}`); - } - const tarPath = join(tmp, String(tarName)); - - const extract = Bun.spawn( - ['tar', '-xOf', tarPath, 'package/package.json'], - { stderr: 'pipe', stdin: 'ignore', stdout: 'pipe' } - ); - const [manifestText, tarStderr, extractExit] = await Promise.all([ - new Response(extract.stdout).text(), - new Response(extract.stderr).text(), - extract.exited, - ]); - if (extractExit !== 0) { - const detail = tarStderr.trim() || '(no stderr output)'; - throw new Error( - `tar extraction failed for ${ws.name} (exit ${extractExit}): ${detail}` - ); - } - - let packedPackage: PackageJson; - try { - packedPackage = JSON.parse(manifestText) as PackageJson; - } catch (error) { - throw new Error( - `Invalid packed package.json for ${ws.name}: ${(error as Error).message}`, - { cause: error } - ); - } - - const offenders: string[] = []; - for (const [lineNo, line] of manifestText.split('\n').entries()) { - if (line.includes('"workspace:') || line.includes('"catalog:')) { - offenders.push(` line ${lineNo + 1}: ${line.trim()}`); - } - } - if (offenders.length > 0) { - const relPath = relative(REPO_ROOT, ws.path); - const hint = - ' Hint: `bun publish` rewrites these at pack time — verify the package was packed via bun, not npm.'; - throw new Error( - `Packed manifest for ${ws.name} (${relPath}) contains forbidden ranges:\n${offenders.join('\n')}\n${hint}` - ); - } - const sourcePackage = await readJson( - join(ws.path, 'package.json') - ); - const mismatches = findPackedFirstPartyDependencyMismatches({ - packageName: ws.name, - packagePath: ws.path, - packedPackage, - sourcePackage, - workspacesByName, - }); - if (mismatches.length > 0) { - throw new Error(mismatches.join('\n')); - } - } finally { - await rm(tmp, { force: true, recursive: true }); - } -}; - -/** Run `--check` flow: pack dry-run plus manifest-range assertion per package. */ -const runCheck = async ( - workspaces: readonly Workspace[], - allWorkspaces: readonly Workspace[] -): Promise => { - const workspacesByName = new Map(allWorkspaces.map((ws) => [ws.name, ws])); - for (const ws of workspaces) { - if (ws.isPrivate) { - info(`Skipping ${ws.name} (private)`); - continue; - } - info(`Checking ${ws.name}@${ws.version}...`); - const dryRun = await spawnInherit( - ['bun', 'pm', 'pack', '--dry-run'], - ws.path - ); - if (dryRun !== 0) { - fail(`bun pm pack --dry-run failed for ${ws.name}`); - return 1; - } - try { - await assertManifestClean(ws, workspacesByName); - } catch (error) { - fail((error as Error).message); - return 1; - } - success(`${ws.name}@${ws.version} pack check passed`); - } - console.log(''); - success('All package pack checks passed!'); - return 0; -}; - -/** Run the actual publish flow sequentially. Aborts on first failure. */ -const runPublish = async ( - workspaces: readonly Workspace[], - tag: string, - otp: string | undefined -): Promise => { - for (const ws of workspaces) { - if (ws.isPrivate) { - info(`Skipping ${ws.name} (private)`); - continue; - } - info(`Publishing ${ws.name}@${ws.version}... (tag=${tag})`); - const cmd: string[] = [ - 'bun', - 'publish', - '--access', - 'public', - '--tag', - tag, - ]; - if (otp) { - cmd.push('--otp', otp); - } - const code = await spawnInherit(cmd, ws.path); - if (code !== 0) { - fail( - `Failed to publish ${ws.name} (exit ${code}) — aborting remaining publishes` - ); - return 1; - } - success(`${ws.name}@${ws.version} published`); - } - console.log(''); - success('All packages published!'); - return 0; -}; - -/** Apply `--only` filter, erroring if any requested name is unknown. */ -const applyOnlyFilter = ( - workspaces: readonly Workspace[], - only: readonly string[] | undefined -): readonly Workspace[] => { - if (!only) { - return workspaces; - } - const set = new Set(only); - const names = new Set(workspaces.map((w) => w.name)); - const unknown = [...set].filter((n) => !names.has(n)); - if (unknown.length > 0) { - fail(`--only references unknown packages: ${unknown.join(', ')}`); - process.exit(2); - } - return workspaces.filter((w) => set.has(w.name)); -}; - -const main = async (): Promise => { - const opts = parseArgs(process.argv.slice(2)); - const all = await discoverWorkspaces(); - const sorted = topoSort(all); - const selected = applyOnlyFilter(sorted, opts.only); - - if (selected.length === 0) { - fail('No workspaces selected'); - return 1; - } - - if (opts.mode === 'check') { - return await runCheck(selected, all); - } - - const tag = opts.tag ?? (await resolveDefaultTag()); - if (!tag) { - fail('Could not resolve a dist-tag. Pass --tag explicitly.'); - return 1; - } - return await runPublish(selected, tag, opts.otp); -}; +import { runNativeBunPublishCli } from '@ontrails/trails/release'; if (import.meta.main) { - try { - const code = await main(); - process.exit(code); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - fail(msg); - if (process.env.DEBUG === '1' && error instanceof Error && error.stack) { - console.error(error.stack); - } - process.exit(1); - } + process.exit(await runNativeBunPublishCli(process.argv.slice(2))); } + +export { findPackedFirstPartyDependencyMismatches } from '@ontrails/trails/release';