import * as plugins from "./mod.plugins.js"; import * as paths from "../paths.js"; import { logger } from "../gitzone.logging.js"; import type { ICliMode } from "../helpers.climode.js"; import { getCliMode, printJson } from "../helpers.climode.js"; import { inferVersionTypeFromPending, movePendingToVersion, readPendingChangelog, } from "../helpers.changelog.js"; import { resolveReleaseWorkflow, type IResolvedReleaseWorkflow, } from "../helpers.workflow.js"; import * as commitHelpers from "../mod_commit/mod.helpers.js"; type TTargetStatus = "success" | "already-published" | "skipped" | "failed"; interface ITargetResult { target: string; status: TTargetStatus; message?: string; } export const run = async (argvArg: any) => { const mode = await getCliMode(argvArg); const subcommand = argvArg._?.[1]; if (mode.help || subcommand === "help") { showHelp(mode); return; } if (mode.json) { printJson({ ok: false, error: "JSON output is not supported for mutating release workflows yet. Use `gitzone release --plan` for a human-readable plan.", }); return; } const workflow = await resolveReleaseWorkflow(argvArg); printReleasePlan(workflow); if (workflow.confirmation === "plan") { return; } const smartshellInstance = new plugins.smartshell.Smartshell({ executor: "bash", sourceFilePaths: [], }); const pending = await readPendingChangelog( plugins.path.join(paths.cwd, workflow.changelogFile), workflow.changelogPendingSection, ); if (pending.isEmpty && !argvArg["allow-empty"] && !argvArg.allowEmpty) { logger.log("error", "No pending changelog entries. Nothing to release."); process.exit(1); } const versionType = resolveVersionType(argvArg, pending.block); const projectType = await commitHelpers.detectProjectType(); const currentVersion = await commitHelpers.readCurrentVersion(projectType); const plannedVersion = commitHelpers.calculateNewVersion(currentVersion, versionType); if (workflow.confirmation === "prompt") { if (!mode.interactive) { throw new Error("Release confirmation requires an interactive terminal. Use `-y` or set release.confirmation to `auto`."); } const confirmed = await plugins.smartinteract.SmartInteract.getCliConfirmation( `Release v${plannedVersion} (${versionType}) now?`, true, ); if (!confirmed) { logger.log("info", "Release cancelled."); return; } } let newVersion = plannedVersion; const gitResults: ITargetResult[] = []; const npmResults: ITargetResult[] = []; const dockerResults: ITargetResult[] = []; if (workflow.requireCleanTree) { await verifyCleanTree(smartshellInstance, "Working tree is not clean. Commit or stash changes before releasing."); } if (workflow.runTests) { await runCommandStep(smartshellInstance, "Running tests", workflow.testCommand); } newVersion = await runVersionStep(projectType, versionType); await runChangelogStep(workflow, newVersion); await runReleaseCommitStep(smartshellInstance, newVersion); await runTagStep(smartshellInstance, newVersion); if (workflow.runBuild) { await runCommandStep(smartshellInstance, "Running release build", workflow.buildCommand); await verifyCleanTree(smartshellInstance, "Build produced uncommitted changes. Aborting release."); } if (workflow.targets.includes("git")) { gitResults.push(...(await runGitTarget(smartshellInstance, workflow))); } if (workflow.targets.includes("npm")) { npmResults.push(...(await runNpmTarget(smartshellInstance, workflow))); } if (workflow.targets.includes("docker")) { dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow))); } printReleaseSummary(newVersion, gitResults, npmResults, dockerResults); if ([...gitResults, ...npmResults, ...dockerResults].some((result) => result.status === "failed")) { process.exit(1); } }; function resolveVersionType(argvArg: any, pendingBlock: string): commitHelpers.VersionType { if (argvArg.major) return "major"; if (argvArg.minor) return "minor"; if (argvArg.patch) return "patch"; return inferVersionTypeFromPending(pendingBlock); } async function runCommandStep( smartshellInstance: plugins.smartshell.Smartshell, label: string, command: string, ): Promise { console.log(`\n${label}`); const result = await smartshellInstance.exec(command); if (result.exitCode !== 0) { logger.log("error", `${label} failed. Aborting release.`); process.exit(1); } logger.log("success", `${label} passed.`); } async function verifyCleanTree( smartshellInstance: plugins.smartshell.Smartshell, errorMessage: string, ): Promise { const statusResult = await smartshellInstance.exec("git status --porcelain"); if (statusResult.stdout.trim() !== "") { logger.log("error", errorMessage); console.log(statusResult.stdout); process.exit(1); } } async function runVersionStep( projectType: commitHelpers.ProjectType, versionType: commitHelpers.VersionType, ): Promise { const currentVersion = await commitHelpers.readCurrentVersion(projectType); const newVersion = commitHelpers.calculateNewVersion(currentVersion, versionType); logger.log("info", `Bumping version: ${currentVersion} -> ${newVersion}`); const commitInfo = new plugins.commitinfo.CommitInfo(paths.cwd, versionType); await commitInfo.writeIntoPotentialDirs(); await commitHelpers.updateProjectVersionFiles(projectType, newVersion); return newVersion; } async function runChangelogStep( workflow: IResolvedReleaseWorkflow, newVersion: string, ): Promise { const dateString = new Date().toISOString().slice(0, 10); await movePendingToVersion( plugins.path.join(paths.cwd, workflow.changelogFile), workflow.changelogPendingSection, workflow.changelogVersionHeading, newVersion, dateString, ); } async function runReleaseCommitStep( smartshellInstance: plugins.smartshell.Smartshell, newVersion: string, ): Promise { await smartshellInstance.exec("git add -A"); const result = await smartshellInstance.exec(`git commit -m ${shellQuote(`v${newVersion}`)}`); if (result.exitCode !== 0) { logger.log("error", "Release commit failed."); process.exit(1); } } async function runTagStep( smartshellInstance: plugins.smartshell.Smartshell, newVersion: string, ): Promise { const result = await smartshellInstance.exec(`git tag v${newVersion} -m ${shellQuote(`v${newVersion}`)}`); if (result.exitCode !== 0) { logger.log("error", "Release tag failed."); process.exit(1); } } async function runGitTarget( smartshellInstance: plugins.smartshell.Smartshell, workflow: IResolvedReleaseWorkflow, ): Promise { const currentBranchResult = await smartshellInstance.exec("git branch --show-current"); const currentBranch = currentBranchResult.stdout.trim() || "master"; const commands: Array<{ target: string; command: string }> = []; if (workflow.pushBranch) { commands.push({ target: `${workflow.gitRemote}/${currentBranch}`, command: `git push ${workflow.gitRemote} ${currentBranch}`, }); } if (workflow.pushTags) { commands.push({ target: `${workflow.gitRemote}/tags`, command: `git push ${workflow.gitRemote} --tags`, }); } const results: ITargetResult[] = []; for (const { target, command } of commands) { const result = await smartshellInstance.exec(command); results.push({ target, status: result.exitCode === 0 ? "success" : "failed", message: result.exitCode === 0 ? undefined : "push failed", }); } return results; } async function runNpmTarget( smartshellInstance: plugins.smartshell.Smartshell, workflow: IResolvedReleaseWorkflow, ): Promise { if (!workflow.npmEnabled) { return [{ target: "npm", status: "skipped", message: "disabled" }]; } if (workflow.npmRegistries.length === 0) { return [{ target: "npm", status: "failed", message: "no registries configured" }]; } const results: ITargetResult[] = []; for (const registry of workflow.npmRegistries) { const command = `pnpm publish --registry=${registry} --access=${workflow.npmAccessLevel}`; const result = await smartshellInstance.exec(command); const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`; if (result.exitCode === 0) { results.push({ target: registry, status: "success" }); } else if (isAlreadyPublishedOutput(output) && workflow.npmAlreadyPublished === "success") { results.push({ target: registry, status: "already-published" }); } else { results.push({ target: registry, status: "failed", message: firstMeaningfulLine(output) }); } } return results; } async function runDockerTarget( smartshellInstance: plugins.smartshell.Smartshell, workflow: IResolvedReleaseWorkflow, ): Promise { if (!workflow.dockerEnabled) { return [{ target: "docker", status: "skipped", message: "disabled" }]; } const command = buildTsdockerPushCommand(workflow); const result = await smartshellInstance.exec(command); const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`; return [{ target: workflow.dockerPatterns.length > 0 ? `tsdocker:${workflow.dockerPatterns.join(",")}` : "tsdocker", status: result.exitCode === 0 ? "success" : "failed", message: result.exitCode === 0 ? undefined : firstMeaningfulLine(output), }]; } function buildTsdockerPushCommand(workflow: IResolvedReleaseWorkflow): string { const commandParts = ["tsdocker", "push"]; if (workflow.dockerNoBuild) { commandParts.push("--no-build"); } if (workflow.dockerCached) { commandParts.push("--cached"); } if (workflow.dockerParallel === true) { commandParts.push("--parallel"); } else if (typeof workflow.dockerParallel === "number" && Number.isFinite(workflow.dockerParallel) && workflow.dockerParallel > 0) { commandParts.push(`--parallel=${Math.floor(workflow.dockerParallel)}`); } if (workflow.dockerContext) { commandParts.push(`--context=${shellQuote(workflow.dockerContext)}`); } for (const pattern of workflow.dockerPatterns) { commandParts.push(shellQuote(pattern)); } return commandParts.join(" "); } function isAlreadyPublishedOutput(output: string): boolean { return /previously published versions|cannot publish over|already exists/i.test(output); } function firstMeaningfulLine(output: string): string { return output .split("\n") .map((line) => line.trim()) .find((line) => line.length > 0) || "command failed"; } function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; } function printReleasePlan(workflow: IResolvedReleaseWorkflow): void { console.log(""); console.log("gitzone release - resolved workflow"); console.log(`confirmation: ${workflow.confirmation}`); console.log(`plan: ${workflow.plan.join(" -> ")}`); console.log(`targets: ${workflow.targets.length > 0 ? workflow.targets.join(", ") : "none"}`); console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogPendingSection}`); if (workflow.targets.includes("npm")) { console.log(`npm registries: ${workflow.npmRegistries.length > 0 ? workflow.npmRegistries.join(", ") : "none"}`); } if (workflow.targets.includes("docker")) { console.log(`docker engine: ${workflow.dockerEngine}`); console.log(`docker patterns: ${workflow.dockerPatterns.length > 0 ? workflow.dockerPatterns.join(", ") : "all Dockerfiles"}`); console.log(`docker options: ${formatDockerOptions(workflow)}`); } console.log(""); } function formatDockerOptions(workflow: IResolvedReleaseWorkflow): string { const options: string[] = []; if (workflow.dockerCached) options.push("cached"); if (workflow.dockerParallel) options.push(`parallel=${workflow.dockerParallel === true ? "true" : workflow.dockerParallel}`); if (workflow.dockerNoBuild) options.push("no-build"); if (workflow.dockerContext) options.push(`context=${workflow.dockerContext}`); return options.length > 0 ? options.join(", ") : "default"; } function printReleaseSummary( newVersion: string, gitResults: ITargetResult[], npmResults: ITargetResult[], dockerResults: ITargetResult[], ): void { console.log(""); console.log(`Release v${newVersion}`); console.log(""); if (gitResults.length > 0) { console.log("git:"); for (const result of gitResults) { console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`); } } if (npmResults.length > 0) { console.log("npm:"); for (const result of npmResults) { console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`); } } if (dockerResults.length > 0) { console.log("docker:"); for (const result of dockerResults) { console.log(` ${result.target} ${result.status}${result.message ? ` (${result.message})` : ""}`); } } } export function showHelp(mode?: ICliMode): void { if (mode?.json) { printJson({ command: "release", usage: "gitzone release [options]", description: "Creates a versioned release from pending changelog entries and publishes configured artifacts.", flags: [ { flag: "-y, --yes", description: "Run without interactive confirmation" }, { flag: "-t, --test", description: "Enable release preflight tests" }, { flag: "-b, --build", description: "Enable release preflight build" }, { flag: "-p, --push", description: "Enable the git release target" }, { flag: "--target ", description: "Release only selected targets: git,npm,docker" }, { flag: "--npm", description: "Enable the npm release target" }, { flag: "--docker", description: "Enable the tsdocker release target" }, { flag: "--no-publish", description: "Run release core and git target only" }, { flag: "--plan", description: "Show resolved workflow without mutating files" }, ], }); return; } console.log(""); console.log("Usage: gitzone release [options]"); console.log(""); console.log("Creates a versioned release from changelog Pending entries."); console.log(""); console.log("Flags:"); console.log(" -y, --yes Run without interactive confirmation"); console.log(" -t, --test Enable release preflight tests"); console.log(" -b, --build Enable release preflight build"); console.log(" -p, --push Enable the git release target"); console.log(" --target Release only selected targets: git,npm,docker"); console.log(" --npm Enable the npm release target"); console.log(" --docker Enable the tsdocker release target"); console.log(" --no-publish Run release core and git target only"); console.log(" --major|--minor|--patch Override inferred semver level"); console.log(" --plan Show resolved workflow without mutating files"); console.log(""); }