feat(cli): split commit and release into target-based workflows

This commit is contained in:
2026-05-10 10:01:09 +00:00
parent 738fbaa64f
commit 0e27d54ad2
22 changed files with 1938 additions and 1057 deletions
+393
View File
@@ -0,0 +1,393 @@
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, newVersion)));
}
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<void> {
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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
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<ITargetResult[]> {
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<ITargetResult[]> {
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,
newVersion: string,
): Promise<ITargetResult[]> {
if (!workflow.dockerEnabled) {
return [{ target: "docker", status: "skipped", message: "disabled" }];
}
if (workflow.dockerImages.length === 0) {
return [{ target: "docker", status: "failed", message: "no images configured" }];
}
const results: ITargetResult[] = [];
for (const imageTemplate of workflow.dockerImages) {
const image = imageTemplate.replaceAll("{{version}}", newVersion);
const buildResult = await smartshellInstance.exec(`docker build -t ${shellQuote(image)} .`);
if (buildResult.exitCode !== 0) {
results.push({ target: image, status: "failed", message: "docker build failed" });
continue;
}
const pushResult = await smartshellInstance.exec(`docker push ${shellQuote(image)}`);
results.push({
target: image,
status: pushResult.exitCode === 0 ? "success" : "failed",
message: pushResult.exitCode === 0 ? undefined : "docker push failed",
});
}
return results;
}
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 images: ${workflow.dockerImages.length > 0 ? workflow.dockerImages.join(", ") : "none"}`);
}
console.log("");
}
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 <names>", description: "Release only selected targets: git,npm,docker" },
{ flag: "--npm", description: "Enable the npm release target" },
{ flag: "--docker", description: "Enable the Docker 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 <names> Release only selected targets: git,npm,docker");
console.log(" --npm Enable the npm release target");
console.log(" --docker Enable the Docker 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("");
}