/** * Alpha-tagging logic — `--alpha` publish + `--promote` graduation. * * The semantics live in `openspec/specs/alpha-publish-tagging/spec.md`; this * module is the implementation surface those flags drive. Per * openspec/changes/publilo-cli/proposal.md Phase 3, the alpha-specific helpers live here * (not in helpers.ts) so it's clear where to look when extending the * model — e.g. to add `--beta` once `openspec/changes/build-bus-poll-cd/proposal.md`'s validation * signal lands. * * Two-layer design for testability: * * pure: pickNextAlphaN, decideAlphaSkip — operate on inputs the * caller has already fetched. Unit-tested directly without * spawning npm or git. * impure: nextAlphaNumber, alphaSkipDecision — thin wrappers that * fetch from npm/git and delegate to the pure helpers. The * planner uses these; tests mock them at the import * boundary (the workspace.test.ts pattern). */ import { spawnSync } from 'node:child_process'; import { REPO_ROOT } from './helpers'; /** npm dist-tag used by `--alpha` publishes. */ export const ALPHA_TAG = 'alpha'; // ─── Pure helpers ────────────────────────────────────────────────── /** * Parse `@` — handles scoped names where the package * name itself starts with `@` (e.g. `@celilo/e2e@0.7.14-alpha.3`). * Pure — used by both --promote parsing and any test fixture that * needs to round-trip a spec string. */ export function parsePackageSpec(spec: string): { name: string; version: string } { const lastAt = spec.lastIndexOf('@'); if (lastAt <= 0) { throw new Error(`Invalid package spec "${spec}". Expected "@".`); } return { name: spec.slice(0, lastAt), version: spec.slice(lastAt + 1), }; } export function stripAlphaSuffix(version: string): string { return version.replace(/-alpha\.\d+$/, ''); } export function isAlphaVersion(version: string): boolean { return /-alpha\.\d+$/.test(version); } /** * ISS-0083: the npm dist-tag a version must publish under, derived from * its semver prerelease identifier. A prerelease (e.g. `0.5.0-alpha.0`, * `1.0.0-beta.2`, `1.0.0-rc.1`) MUST be tagged with its prerelease name * (`alpha`/`beta`/`rc`/…), NEVER `latest` — otherwise `npm install ` * (no tag) pulls a prerelease, and the prerelease tag the .deb resolves * (`@celilo/cli@alpha`) goes stale. A stable version returns undefined * (publish defaults to `latest`, which is correct for stable). * * The tag is the FIRST dot-separated token of the prerelease component: * 0.5.0-alpha.0 → "alpha" * 1.0.0-beta.2 → "beta" * 1.0.0-rc.1 → "rc" * 1.0.0 → undefined (→ latest) */ export function prereleaseDistTag(version: string): string | undefined { const dash = version.indexOf('-'); if (dash === -1) return undefined; const prerelease = version.slice(dash + 1); const identifier = prerelease.split('.')[0]; return identifier || undefined; } /** * Pure inner of `nextAlphaNumber`. Given the full list of versions * for a package (whatever `npm view versions --json` returned) * and a target semver core, returns the next alpha N to ship. * * - No existing `X.Y.Z-alpha.*` → 0 * - Existing `X.Y.Z-alpha.0`, `X.Y.Z-alpha.1`, `X.Y.Z-alpha.3` → 4 * (gaps in the sequence don't matter; we want max+1, not min-gap) * - Versions outside the target X.Y.Z are ignored. */ export function pickNextAlphaN(versions: string[], semverCore: string): number { const re = new RegExp(`^${semverCore.replace(/\./g, '\\.')}-alpha\\.(\\d+)$`); let max = -1; for (const v of versions) { const m = v.match(re); if (m) { const n = Number.parseInt(m[1], 10); if (n > max) max = n; } } return max + 1; } /** * Inputs the alpha-skip decision actually depends on. Lifted out as a * type so the pure decideAlphaSkip can be tested without mocking npm * or git: callers construct one of these, the function returns the * decision. */ export interface AlphaSkipInputs { /** Next alpha number we'd ship (0 means no prior alpha exists). */ nextN: number; /** * `gitHead` recorded on the prior alpha's package.json. Empty string * when npm returned nothing — we stamp gitHead ourselves on every * alpha publish, but pre-feature alphas (or hand-published ones) may * not have it. */ priorGitHead: string; /** * Was the prior gitHead reachable in the current git history? When * false (history rewritten, shallow clone, etc.), the planner can't * tell whether the source moved — defaults to "publish to be safe." */ priorHeadReachable: boolean; /** * Whether any commits between priorGitHead..HEAD touched this * package's source. Only meaningful when priorHeadReachable is true. */ sourceChangedSincePrior: boolean; /** Tag of the prior version (e.g. "0.7.14-alpha.3"). Used in skip reason text. */ priorVersion: string; } export type AlphaSkipDecision = { skip: false; reason?: string } | { skip: true; reason: string }; /** * Pure decision function. Given the inputs the planner has gathered, * decide whether this package should skip its alpha publish this run. * * Conservative bias: any signal we can't read cleanly defaults to * publish. Specifically: * - N === 0 (no prior alpha) → publish (nothing to compare to). * - priorGitHead is empty → publish (can't tell if source changed). * - priorHead unreachable → publish (history likely got rewritten). * - sourceChanged → publish. * - everything else → skip (clean diff against prior alpha). */ export function decideAlphaSkip(inputs: AlphaSkipInputs): AlphaSkipDecision { if (inputs.nextN === 0) return { skip: false }; if (!inputs.priorGitHead) { return { skip: false, reason: `no gitHead on ${inputs.priorVersion}; publishing` }; } if (!inputs.priorHeadReachable) { return { skip: false, reason: `prior gitHead ${inputs.priorGitHead.slice(0, 8)} unreachable; publishing`, }; } if (inputs.sourceChangedSincePrior) { return { skip: false }; } return { skip: true, reason: `no source changes since ${inputs.priorVersion} (gitHead ${inputs.priorGitHead.slice(0, 8)})`, }; } // ─── Impure wrappers (npm + git I/O) ─────────────────────────────── /** * Next alpha number for `-alpha.N` on npm. Returns 0 if no * alphas exist for this core (or the package isn't on npm yet). Matches * the spec: N auto-increments per X.Y.Z, resets when the publisher bumps * the core. */ export function nextAlphaNumber(name: string, semverCore: string): number { const r = spawnSync('npm', ['view', name, 'versions', '--json'], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', }); if (r.status !== 0) return 0; let versions: string[]; try { const parsed = JSON.parse(r.stdout || '[]'); versions = Array.isArray(parsed) ? parsed : [String(parsed)]; } catch { return 0; } return pickNextAlphaN(versions, semverCore); } /** * Did this package's source change since the last alpha publish? * * Reads the prior alpha's `gitHead` (stamped onto package.json by the * executor during prior publishes) and asks git whether any commits * between that head and current HEAD touched this package's source. * Delegates the actual decision to `decideAlphaSkip` so the logic can * be unit-tested without spawning anything. */ export function alphaSkipDecision( pkg: string, name: string, semverCore: string, nextN: number, ): AlphaSkipDecision { if (nextN === 0) return decideAlphaSkip({ nextN, priorGitHead: '', priorHeadReachable: false, sourceChangedSincePrior: false, priorVersion: '', }); const priorVersion = `${semverCore}-alpha.${nextN - 1}`; const r = spawnSync('npm', ['view', `${name}@${priorVersion}`, 'gitHead'], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8', }); // `npm view` couldn't reach the registry / version — bias to publish. if (r.status !== 0) return { skip: false }; const priorGitHead = r.stdout.trim(); if (!priorGitHead) { return decideAlphaSkip({ nextN, priorGitHead: '', priorHeadReachable: false, sourceChangedSincePrior: false, priorVersion, }); } const log = spawnSync( 'git', ['log', '--format=%H', `${priorGitHead}..HEAD`, '--', pkg, `:(exclude)${pkg}/node_modules`], { cwd: REPO_ROOT, stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }, ); const priorHeadReachable = log.status === 0; const sourceChangedSincePrior = priorHeadReachable && log.stdout.trim() !== ''; return decideAlphaSkip({ nextN, priorGitHead, priorHeadReachable, sourceChangedSincePrior, priorVersion, }); }