394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
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("");
|
|
}
|