/** * Workspace publish — Phase 1 of `celilo publish`. * * Planner/executor split per openspec/changes/publilo-cli/proposal.md Phase 2: * * planWorkspace(opts) → WorkspaceItem[] // pure, no mutations * executeWorkspace(plan, ...) // mutates package.json, * // runs bun publish, * // restores, verifies * * The planner figures out which versions each package would publish at * (real, alpha-N, promoted-base), which workspace:^ deps need explicit * rewriting, whether the package should be skipped (already on npm, * source unchanged since prior alpha), and which pre-publish hooks * fire. The executor applies the plan top-to-bottom — no decisions are * made during execution, just side effects. */ import { spawnSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { ALPHA_TAG, alphaSkipDecision, nextAlphaNumber, prereleaseDistTag, stripAlphaSuffix, } from './alpha'; import { REPO_ROOT, isPublished, readNpmPublishTarget, readPkg } from './helpers'; import type { PackageJson, PublishMode, PublishResult, RewriteOptions, WorkspaceItem, WorkspaceRewrite, WorkspaceVersionMap, } from './types'; // ─── Planner ─────────────────────────────────────────────────────── export interface PlanWorkspaceInput { mode: PublishMode; packages: readonly string[]; /** * Starting workspace-version map (typically built from each * package.json's `version` field). The planner mutates a local copy * — in alpha mode it tightens the map to the alpha versions so * sibling rewrites resolve correctly. Doesn't mutate the input. */ baseWorkspaceVersions: WorkspaceVersionMap; gitHead: string; } export interface PlanWorkspaceOutput { items: WorkspaceItem[]; /** * The map of name → versionToPublish for non-skipped items. The * executor uses this to rewrite workspace:^ deps consistently across * sibling packages in the same publish run. */ workspaceVersions: WorkspaceVersionMap; } /** * Pre-publish hooks that fire for @celilo/e2e (the only package that * needs one today): refresh the bundled npm-compat registry-server source. * Sim-content caches and standard-module netapps are no longer staged into * the tarball at publish — a monorepo-free consumer fetches them from the * public celilo sources at `cele2e build-infra` time (ce-qwz Decisions * 2B + 3, ce-i2i). */ function workspaceHooksFor(pkg: string): WorkspaceItem['hooks'] { if (pkg !== 'packages/e2e') return []; return ['registryServerBundle']; } /** * Build the workspace publish plan. Pure given the world-state reads * exposed by helpers (npm view, git log) — same world → same plan. * * In promote mode, only the named package is planned; everything else * is filtered out. In alpha mode, the local workspaceVersions map gets * tightened to alpha versions as we iterate, so later packages in * dependency order can pin to the alpha version of earlier siblings. */ export function planWorkspace(opts: PlanWorkspaceInput): PlanWorkspaceOutput { const { mode, packages, baseWorkspaceVersions } = opts; const workspaceVersions = new Map(baseWorkspaceVersions); const packagesToPlan: readonly string[] = mode.kind === 'promote' ? packages.filter((p) => readPkg(p).name === mode.target.name) : packages; if (mode.kind === 'promote' && packagesToPlan.length === 0) { throw new Error( `--promote target "${mode.target.name}" is not a known workspace package. Known packages: ${[ ...workspaceVersions.keys(), ].join(', ')}`, ); } const items: WorkspaceItem[] = []; for (const pkg of packagesToPlan) { const { name, version } = readPkg(pkg); if (!name || !version) { throw new Error(`${pkg}/package.json missing name or version`); } let versionToPublish: string; let skipReason: string | undefined; let tag: WorkspaceItem['tag']; if (mode.kind === 'promote') { versionToPublish = stripAlphaSuffix(mode.target.version); if (versionToPublish === mode.target.version) { throw new Error( `--promote target "${mode.target.name}@${mode.target.version}" is not an alpha (no -alpha.N suffix).`, ); } } else if (mode.kind === 'alpha') { const n = nextAlphaNumber(name, version); const decision = alphaSkipDecision(pkg, name, version, n); if (decision.skip) { // Skipped (unchanged since its last alpha): it will NOT be // republished, so consumers must pin to the alpha that already // exists on npm (alpha.{n-1}), not the computed-next alpha.{n} // which would never be published. (n >= 1 whenever skip is true — // nextN === 0 can't skip.) Pinning to alpha.{n} made dependents // unpublishable: "@celilo/cli-display@0.1.9-alpha.1 is not on npm". skipReason = decision.reason; versionToPublish = `${version}-alpha.${n - 1}`; } else { versionToPublish = `${version}-alpha.${n}`; } tag = ALPHA_TAG; workspaceVersions.set(name, versionToPublish); } else { versionToPublish = version; // ISS-0083: a prerelease version in package.json (e.g. 0.5.0-alpha.0) // must publish under its prerelease dist-tag, never `latest`. Only // stable versions get `latest` (tag stays undefined). tag = prereleaseDistTag(versionToPublish); if (isPublished(name, versionToPublish)) { skipReason = 'already published'; } } items.push({ pkg, name, baseVersion: version, versionToPublish, tag, rewriteOptions: { targetVersion: versionToPublish !== version ? versionToPublish : undefined, exactPins: mode.kind === 'alpha', gitHead: mode.kind === 'alpha' ? opts.gitHead : undefined, }, hooks: workspaceHooksFor(pkg), skipReason, }); } return { items, workspaceVersions }; } // ─── Write-side helpers (used by executor) ───────────────────────── /** * Bun's publish-time rewrite of `workspace:^` is unreliable — empirically, * it's been baking in `^0.1.0` regardless of the actual current version * of the dep. We rewrite explicitly here: * workspace:^ → ^ (or exact, in alpha mode) * workspace:~ → ~ (or exact, in alpha mode) * workspace:* → * (passes through; means "any version") * workspace:X.Y.Z → X.Y.Z (explicit; passes through) * * Returns the original package.json content so the caller can restore * after `bun publish` runs. We don't want to commit the rewritten form * — `workspace:^` in source is more readable for dev. */ export function rewriteWorkspaceDeps( pkg: string, versions: WorkspaceVersionMap, opts: RewriteOptions = {}, /** * Optional override for the repo root. Production code uses the * module-resolved REPO_ROOT (the real monorepo); tests can hand in * a synthetic workspace path so the rewrite hits temp-dir files. */ repoRoot: string = REPO_ROOT, ): { original: string; rewrites: WorkspaceRewrite[] } { const path = join(repoRoot, pkg, 'package.json'); const original = readFileSync(path, 'utf-8'); const parsed = JSON.parse(original) as PackageJson & { gitHead?: string }; const rewrites: WorkspaceRewrite[] = []; let mutated = false; if (opts.targetVersion !== undefined && opts.targetVersion !== parsed.version) { parsed.version = opts.targetVersion; mutated = true; } if (opts.gitHead !== undefined && opts.gitHead !== parsed.gitHead) { parsed.gitHead = opts.gitHead; mutated = true; } const buckets: Array = [ 'dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', ]; for (const bucket of buckets) { const deps = parsed[bucket] as Record | undefined; if (!deps) continue; for (const [name, oldSpec] of Object.entries(deps)) { if (!oldSpec.startsWith('workspace:')) continue; const wsRange = oldSpec.slice('workspace:'.length); const targetVersion = versions.get(name); if (!targetVersion) { continue; } let newSpec: string; if (wsRange === '*' || wsRange === '') { newSpec = '*'; } else if (opts.exactPins) { newSpec = targetVersion; } else if (wsRange === '^') { newSpec = `^${targetVersion}`; } else if (wsRange === '~') { newSpec = `~${targetVersion}`; } else { newSpec = wsRange; } deps[name] = newSpec; rewrites.push({ depName: name, bucket: String(bucket), oldSpec, newSpec }); } } if (rewrites.length > 0 || mutated) { const trailingNl = original.endsWith('\n') ? '\n' : ''; writeFileSync(path, JSON.stringify(parsed, null, 2) + trailingNl); } return { original, rewrites }; } export function restorePackageJson( pkg: string, original: string, repoRoot: string = REPO_ROOT, ): void { writeFileSync(join(repoRoot, pkg, 'package.json'), original); } /** * After `bun publish` succeeds, ask npm what dep versions the freshly- * published package.json contains. If any rewrite we made didn't make * it through to the published artifact, fail loudly — don't silently * leave a broken pin on npm. */ export async function verifyPublishedDeps( name: string, version: string, rewrites: WorkspaceRewrite[], /** * Optional override pointing `npm view` at a different registry. * Production omits this (uses the operator's configured npm/bun * registry); tests pass their verdaccio URL. */ registryUrl?: string, ): Promise { // npm's registry replication is eventually-consistent. A package // published 1s ago can still return empty for `npm view ... dep.X` // even though the publish succeeded. Retry with backoff before // declaring failure — only fail if the pin is genuinely WRONG, not // just temporarily missing. for (const r of rewrites) { let actualPin = ''; let attempt = 0; const maxAttempts = 6; const delays = [500, 1000, 2000, 3000, 5000, 8000]; const baseArgs = ['view', `${name}@${version}`, `${r.bucket}.${r.depName}`]; const args = registryUrl ? [...baseArgs, '--registry', registryUrl] : baseArgs; while (attempt < maxAttempts) { const result = spawnSync('npm', args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', }); actualPin = (result.stdout ?? '').trim(); if (actualPin) break; attempt++; if (attempt < maxAttempts) { const delay = delays[attempt - 1]; process.stdout.write( ` (verify retry ${attempt} for ${r.depName} — npm view returned empty, waiting ${delay}ms…)\n`, ); await new Promise((r2) => setTimeout(r2, delay)); } } if (!actualPin) { console.warn( `⚠ verify could not read ${name}@${version} ${r.bucket}.${r.depName} from npm after ${maxAttempts} retries. Skipping verify for this dep.`, ); continue; } if (actualPin !== r.newSpec) { console.error( `✗ ${name}@${version} published with ${r.depName} pinned at "${actualPin}", expected "${r.newSpec}"`, ); console.error( ' The pre-publish workspace:^ rewrite either failed silently or\n' + ' bun stripped our edit. Inspect the published artifact and the\n' + ' source package.json. The package is on npm with the wrong pin —\n' + ' consumers will get a stale dep until you republish.', ); process.exit(1); } } } /** * Build the `bun publish` argv for a workspace item. When a private * registry target is configured (a deployed npm-cache-node) AND the * package is @celilo/*-scoped, point bun at it via --registry; otherwise * publish to the default registry (npmjs — current behavior). * openspec/changes/private-npm-registry/proposal.md Phase 3.1 / openspec/changes/publilo-cli/proposal.md decision 10. */ export function buildPublishArgs( item: Pick, registryTarget: string | null, ): string[] { const args = ['publish', '--access', 'public']; if (item.tag) args.push('--tag', item.tag); if (registryTarget && item.name.startsWith('@celilo/')) { args.push('--registry', registryTarget); } return args; } // ─── Executor ────────────────────────────────────────────────────── export interface ExecuteWorkspaceInput { items: WorkspaceItem[]; workspaceVersions: WorkspaceVersionMap; mode: PublishMode; /** From the confirm() helper in index.ts — passed in to avoid coupling. */ confirm: (question: string) => Promise; } export async function executeWorkspace(input: ExecuteWorkspaceInput): Promise { const { items, workspaceVersions, mode, confirm } = input; const published: PublishResult['published'] = []; const skipped: string[] = []; // When an npm-cache-node is configured, @celilo/* tarballs go there // (the cache forwards upstream under policy); unset → npmjs. const registryTarget = readNpmPublishTarget(); if (registryTarget) { console.log(`\nPublishing @celilo/* to configured registry: ${registryTarget}`); } for (const item of items) { const { pkg, name, baseVersion, versionToPublish } = item; if (item.skipReason) { console.log(`\n→ ${name}@${versionToPublish} skipped (${item.skipReason}).`); skipped.push(`${name}@${versionToPublish} (${item.skipReason})`); continue; } console.log('\n──────────────────────────────────────────────'); const tag = mode.kind === 'alpha' ? ' [alpha]' : mode.kind === 'promote' ? ' [promote]' : ''; console.log(` ${name}@${versionToPublish} (${pkg})${tag}`); console.log('──────────────────────────────────────────────\n'); // Per-package pre-publish hooks. Driven off the WorkspaceItem so the // dry-run plan listed them too. if (item.hooks.includes('registryServerBundle')) { const { ensureRegistryServerBundle } = await import( join(REPO_ROOT, 'packages/e2e/src/registry-bundle.ts') ); ensureRegistryServerBundle(join(REPO_ROOT, 'packages/e2e')); console.log( 'Refreshed packages/e2e/registry-server/ bundle from packages/registry-server.\n', ); } const { original: pkgJsonOriginal, rewrites: workspaceRewrites } = rewriteWorkspaceDeps( pkg, workspaceVersions, item.rewriteOptions, ); if (workspaceRewrites.length > 0) { console.log('Rewrote workspace deps to explicit versions:'); for (const r of workspaceRewrites) { console.log(` ${r.bucket}.${r.depName}: ${r.oldSpec} → ${r.newSpec}`); } console.log(); } // Belt-and-suspenders: refuse to publish if any @celilo/* dep // (after workspace-rewrite) doesn't exist on npm. We publish in dep // order, so this should be a no-op — but it catches operator typos // and any external `bun publish` invocation that bypasses the // ordered loop. // Hand the guard the receipts for everything THIS RUN already // published. npm's registry is eventually-consistent (measured 90s // and 300s for two packages published two seconds apart), so without // this the guard refuses to publish a package whose dependency the // same run published successfully seconds earlier — celilo#1377. const checkResult = spawnSync('bun', [join(REPO_ROOT, 'scripts/check-publishable.ts'), pkg], { cwd: REPO_ROOT, stdio: 'inherit', env: { ...process.env, CELILO_PUBLISHED_THIS_RUN: published.map((p) => `${p.name}@${p.version}`).join(','), }, }); if (checkResult.status !== 0) { restorePackageJson(pkg, pkgJsonOriginal); console.error( `✗ Refusing to publish ${name}@${versionToPublish} — see check-publishable output above.`, ); process.exit(1); } const publishArgs = buildPublishArgs(item, registryTarget); let publishStatus: number | null = null; let publishError: unknown = null; try { spawnSync('bun', [...publishArgs, '--dry-run'], { cwd: join(REPO_ROOT, pkg), stdio: 'inherit', }); if (!(await confirm(`\nPublish ${name}@${versionToPublish} for real? [y/N] `))) { console.log('Skipped.'); skipped.push(`${name}@${versionToPublish} (manual skip)`); continue; } const r = spawnSync('bun', publishArgs, { cwd: join(REPO_ROOT, pkg), stdio: 'inherit' }); publishStatus = r.status; } catch (err) { publishError = err; } finally { // ALWAYS restore the source package.json so workspace:^ stays in // git. Even on publish failure or operator skip. restorePackageJson(pkg, pkgJsonOriginal); } if (publishError) throw publishError; if (publishStatus !== 0) { // npm view can be transiently stale — recheck once before failing. if (isPublished(name, versionToPublish)) { console.log( `\n→ ${name}@${versionToPublish} reports already-published on recheck (initial isPublished hit a stale npm view). Skipping.`, ); skipped.push(`${name}@${versionToPublish} (already published — detected on retry)`); continue; } console.error(`✗ Publish failed for ${name}@${versionToPublish}`); process.exit(publishStatus ?? 1); } if (workspaceRewrites.length > 0) { // Verify against the same registry we published to — a cache node // forwards upstream under policy, so the version may not be on // npmjs yet. await verifyPublishedDeps( name, versionToPublish, workspaceRewrites, registryTarget ?? undefined, ); } // Silence the unused-var warning on baseVersion — we keep the value // on the WorkspaceItem for dry-run output ("0.7.13 → 0.7.13-alpha.0"). void baseVersion; console.log(`✓ Published ${name}@${versionToPublish}`); published.push({ name, version: versionToPublish }); } return { published, skipped }; }