/** * Release metadata stamped into every .netapp at publish time. * * Per CELILO_UPDATE D5, each published package carries a `release.json` * recording git SHA, branch, dirty flag, publish timestamp, the CLI * version that produced the build, and an optional one-line release * note. `system audit` and `system update` surface this to give the * user a clear "what changed?" signal without requiring a curated * CHANGELOG. * * Pure helpers for building / parsing live here. The actual `release.json` * write happens inside `buildModule` after the staged copy is set up. */ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; export interface ReleaseMetadata { /** Module ID (matches manifest.id) */ module_id: string; /** Module version including revision suffix (e.g. "1.2.0+3") */ version: string; /** Git commit SHA of the source tree at build time, or null if not in a git checkout */ git_sha: string | null; /** Git branch name, or null */ git_branch: string | null; /** True if the working tree had uncommitted changes when this was built */ git_dirty: boolean; /** ISO-8601 UTC timestamp */ published_at: string; /** Version of @celilo/cli that produced the build */ published_by_cli_version: string; /** Optional one-line release note from --message */ message: string | null; } /** * Pure constructor — takes pre-collected git state and assembles the * structured metadata. The `gitInfo` and `cliVersion` come from the * caller; this keeps the function trivially testable. */ export function buildReleaseMetadata(opts: { moduleId: string; version: string; git: GitInfo; cliVersion: string; message: string | null; publishedAt?: Date; }): ReleaseMetadata { return { module_id: opts.moduleId, version: opts.version, git_sha: opts.git.sha, git_branch: opts.git.branch, git_dirty: opts.git.dirty, published_at: (opts.publishedAt ?? new Date()).toISOString(), published_by_cli_version: opts.cliVersion, message: opts.message, }; } export interface GitInfo { sha: string | null; branch: string | null; dirty: boolean; } /** * Resolve the running CLI version by reading the bundled package.json. * Returns "0.0.0" if the file isn't found (dev-from-source without a * package.json shouldn't ever happen in practice, but we shouldn't * crash a publish on it). */ export function readInstalledCliVersion(): string { const here = dirname(fileURLToPath(import.meta.url)); const candidates = [ join(here, '..', '..', '..', 'package.json'), join(process.cwd(), 'package.json'), ]; for (const path of candidates) { if (existsSync(path)) { try { const pkg = JSON.parse(readFileSync(path, 'utf-8')) as { version?: string }; if (pkg.version) return pkg.version; } catch { // try the next candidate } } } return '0.0.0'; } export type GitCommandRunner = (args: string[], cwd: string) => string | null; /** * Default runner: invoke the system `git` binary, return stdout, or * null on any failure (not a git repo, git not installed, etc.). */ export function makeRealGitRunner(): GitCommandRunner { // Defer the import so test environments without bun:child_process aren't // affected, and so this file stays pure for the unit tests. const { execFileSync } = require('node:child_process') as typeof import('node:child_process'); return (args, cwd) => { try { return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], }) .toString() .trim(); } catch { return null; } }; } /** * Collect git SHA, branch, and dirty state from the working tree at * `sourceDir`. Returns nulls / false if the directory isn't in a git * checkout (or git isn't available). */ export function collectGitInfo(sourceDir: string, run: GitCommandRunner): GitInfo { const sha = run(['rev-parse', 'HEAD'], sourceDir); if (!sha) return { sha: null, branch: null, dirty: false }; const branch = run(['rev-parse', '--abbrev-ref', 'HEAD'], sourceDir); // `git status --porcelain` prints one line per modified/untracked file. // Empty output = clean. Null (command failure) is treated as not-dirty // because we don't want to falsely block a publish. // // The `-- .` pathspec is load-bearing (#544). `git status` reports the WHOLE // repository regardless of cwd, so passing `sourceDir` as cwd scoped nothing: // every caller asks about one module, and got back the dirt of all of them // plus the repo root. The release pipeline runs `bun install` between module // publishes, which rewrites the tracked `bun.lock` at the root — so modules // 1..N published fine and the next one failed with "Working tree at // modules/ has uncommitted changes", naming a directory that // was clean and sending you to inspect it. Order-dependent, so it looked // like a random module failing. const status = run(['status', '--porcelain', '--', '.'], sourceDir); const dirty = status !== null && status.length > 0; return { sha, branch: branch === 'HEAD' ? null : branch, // detached HEAD reports "HEAD" dirty, }; } /** * Filename for the metadata file at the root of every .netapp. */ export const RELEASE_METADATA_FILENAME = 'release.json';