feat(cli): split commit and release into target-based workflows
This commit is contained in:
@@ -51,6 +51,14 @@ export let run = async () => {
|
||||
await modCommit.run(argvArg);
|
||||
});
|
||||
|
||||
/**
|
||||
* create a release from pending changelog entries
|
||||
*/
|
||||
gitzoneSmartcli.addCommand("release").subscribe(async (argvArg) => {
|
||||
const modRelease = await import("./mod_release/index.js");
|
||||
await modRelease.run(argvArg);
|
||||
});
|
||||
|
||||
/**
|
||||
* deprecate a package on npm
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import * as plugins from "./plugins.js";
|
||||
|
||||
export type TChangelogBucket =
|
||||
| "Breaking Changes"
|
||||
| "Features"
|
||||
| "Fixes"
|
||||
| "Documentation"
|
||||
| "Maintenance";
|
||||
|
||||
export interface IChangelogEntry {
|
||||
type: string;
|
||||
scope: string;
|
||||
message: string;
|
||||
details?: string[];
|
||||
}
|
||||
|
||||
export interface IPendingChangelog {
|
||||
block: string;
|
||||
isEmpty: boolean;
|
||||
}
|
||||
|
||||
const bucketForCommitType = (commitType: string): TChangelogBucket => {
|
||||
switch (commitType) {
|
||||
case "BREAKING CHANGE":
|
||||
return "Breaking Changes";
|
||||
case "feat":
|
||||
return "Features";
|
||||
case "fix":
|
||||
return "Fixes";
|
||||
case "docs":
|
||||
return "Documentation";
|
||||
default:
|
||||
return "Maintenance";
|
||||
}
|
||||
};
|
||||
|
||||
const readChangelog = async (filePath: string): Promise<string> => {
|
||||
if (!(await plugins.smartfs.file(filePath).exists())) {
|
||||
return "# Changelog\n\n";
|
||||
}
|
||||
return (await plugins.smartfs.file(filePath).encoding("utf8").read()) as string;
|
||||
};
|
||||
|
||||
const writeChangelog = async (filePath: string, content: string): Promise<void> => {
|
||||
await plugins.smartfs.file(filePath).encoding("utf8").write(content.endsWith("\n") ? content : `${content}\n`);
|
||||
};
|
||||
|
||||
const findPendingSection = (
|
||||
content: string,
|
||||
sectionName: string,
|
||||
): { start: number; bodyStart: number; end: number } | null => {
|
||||
const headingRegex = new RegExp(`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
|
||||
const match = headingRegex.exec(content);
|
||||
if (!match || match.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bodyStart = match.index + match[0].length;
|
||||
const rest = content.slice(bodyStart);
|
||||
const nextHeadingMatch = /^##\s+/m.exec(rest);
|
||||
const end = nextHeadingMatch ? bodyStart + nextHeadingMatch.index : content.length;
|
||||
return { start: match.index, bodyStart, end };
|
||||
};
|
||||
|
||||
export const ensurePendingSection = async (
|
||||
filePath: string,
|
||||
sectionName = "Pending",
|
||||
): Promise<string> => {
|
||||
let content = await readChangelog(filePath);
|
||||
if (findPendingSection(content, sectionName)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const pendingSection = `## ${sectionName}\n\n`;
|
||||
const titleMatch = /^#\s+.+$/m.exec(content);
|
||||
if (titleMatch && titleMatch.index !== undefined) {
|
||||
const insertAt = titleMatch.index + titleMatch[0].length;
|
||||
content = `${content.slice(0, insertAt)}\n\n${pendingSection}${content.slice(insertAt).replace(/^\n+/, "")}`;
|
||||
} else {
|
||||
content = `# Changelog\n\n${pendingSection}${content}`;
|
||||
}
|
||||
|
||||
await writeChangelog(filePath, content);
|
||||
return content;
|
||||
};
|
||||
|
||||
export const appendPendingChangelogEntry = async (
|
||||
filePath: string,
|
||||
sectionName: string,
|
||||
entry: IChangelogEntry,
|
||||
): Promise<void> => {
|
||||
let content = await ensurePendingSection(filePath, sectionName);
|
||||
const pendingSection = findPendingSection(content, sectionName)!;
|
||||
let pendingBody = content.slice(pendingSection.bodyStart, pendingSection.end);
|
||||
const bucket = bucketForCommitType(entry.type);
|
||||
const bucketHeading = `### ${bucket}`;
|
||||
|
||||
const entryLines = [`- ${entry.message}${entry.scope ? ` (${entry.scope})` : ""}`];
|
||||
for (const detail of entry.details || []) {
|
||||
entryLines.push(` - ${detail}`);
|
||||
}
|
||||
const renderedEntry = entryLines.join("\n");
|
||||
|
||||
const bucketRegex = new RegExp(`^###\\s+${bucket.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
|
||||
const bucketMatch = bucketRegex.exec(pendingBody);
|
||||
if (!bucketMatch || bucketMatch.index === undefined) {
|
||||
pendingBody = `${pendingBody.trimEnd()}\n\n${bucketHeading}\n\n${renderedEntry}\n`;
|
||||
} else {
|
||||
const bucketBodyStart = bucketMatch.index + bucketMatch[0].length;
|
||||
const afterBucket = pendingBody.slice(bucketBodyStart);
|
||||
const nextBucketMatch = /^###\s+/m.exec(afterBucket);
|
||||
const insertAt = nextBucketMatch ? bucketBodyStart + nextBucketMatch.index : pendingBody.length;
|
||||
const beforeInsert = pendingBody.slice(0, insertAt).trimEnd();
|
||||
const afterInsert = pendingBody.slice(insertAt).replace(/^\n+/, "");
|
||||
pendingBody = `${beforeInsert}\n${renderedEntry}\n\n${afterInsert}`;
|
||||
}
|
||||
|
||||
content = `${content.slice(0, pendingSection.bodyStart)}\n${pendingBody.trim()}\n\n${content.slice(pendingSection.end).replace(/^\n+/, "")}`;
|
||||
await writeChangelog(filePath, content);
|
||||
};
|
||||
|
||||
export const readPendingChangelog = async (
|
||||
filePath: string,
|
||||
sectionName = "Pending",
|
||||
): Promise<IPendingChangelog> => {
|
||||
const content = await ensurePendingSection(filePath, sectionName);
|
||||
const pendingSection = findPendingSection(content, sectionName)!;
|
||||
const block = content.slice(pendingSection.bodyStart, pendingSection.end).trim();
|
||||
return {
|
||||
block,
|
||||
isEmpty: block.length === 0,
|
||||
};
|
||||
};
|
||||
|
||||
export const inferVersionTypeFromPending = (pendingBlock: string): "patch" | "minor" | "major" => {
|
||||
if (/^###\s+Breaking Changes\s*$/m.test(pendingBlock)) {
|
||||
return "major";
|
||||
}
|
||||
if (/^###\s+Features\s*$/m.test(pendingBlock)) {
|
||||
return "minor";
|
||||
}
|
||||
return "patch";
|
||||
};
|
||||
|
||||
export const movePendingToVersion = async (
|
||||
filePath: string,
|
||||
sectionName: string,
|
||||
versionHeading: string,
|
||||
version: string,
|
||||
dateString: string,
|
||||
): Promise<void> => {
|
||||
let content = await ensurePendingSection(filePath, sectionName);
|
||||
const pendingSection = findPendingSection(content, sectionName)!;
|
||||
const pendingBlock = content.slice(pendingSection.bodyStart, pendingSection.end).trim();
|
||||
if (!pendingBlock) {
|
||||
throw new Error("No pending changelog entries. Nothing to release.");
|
||||
}
|
||||
|
||||
const renderedHeading = versionHeading
|
||||
.replaceAll("{{version}}", version)
|
||||
.replaceAll("{{date}}", dateString);
|
||||
const nextContent = content.slice(pendingSection.end).replace(/^\n+/, "");
|
||||
content = `${content.slice(0, pendingSection.bodyStart)}\n\n${renderedHeading}\n\n${pendingBlock}\n\n${nextContent}`;
|
||||
await writeChangelog(filePath, content);
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
export const CURRENT_GITZONE_CLI_SCHEMA_VERSION = 2;
|
||||
|
||||
export interface ISmartconfigMigrationResult {
|
||||
migrated: boolean;
|
||||
fromVersion: number;
|
||||
toVersion: number;
|
||||
}
|
||||
|
||||
const CLI_NAMESPACE = "@git.zone/cli";
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const ensureObject = (parent: Record<string, any>, key: string): Record<string, any> => {
|
||||
if (!isPlainObject(parent[key])) {
|
||||
parent[key] = {};
|
||||
}
|
||||
return parent[key];
|
||||
};
|
||||
|
||||
const migrateNamespaceKeys = (smartconfigJson: Record<string, any>): boolean => {
|
||||
let migrated = false;
|
||||
const migrations = [
|
||||
{ oldKey: "gitzone", newKey: CLI_NAMESPACE },
|
||||
{ oldKey: "tsdoc", newKey: "@git.zone/tsdoc" },
|
||||
{ oldKey: "npmdocker", newKey: "@git.zone/tsdocker" },
|
||||
{ oldKey: "npmci", newKey: "@ship.zone/szci" },
|
||||
{ oldKey: "szci", newKey: "@ship.zone/szci" },
|
||||
];
|
||||
|
||||
for (const { oldKey, newKey } of migrations) {
|
||||
if (!isPlainObject(smartconfigJson[oldKey])) {
|
||||
continue;
|
||||
}
|
||||
if (!isPlainObject(smartconfigJson[newKey])) {
|
||||
smartconfigJson[newKey] = smartconfigJson[oldKey];
|
||||
} else {
|
||||
smartconfigJson[newKey] = {
|
||||
...smartconfigJson[oldKey],
|
||||
...smartconfigJson[newKey],
|
||||
};
|
||||
}
|
||||
delete smartconfigJson[oldKey];
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
return migrated;
|
||||
};
|
||||
|
||||
const migrateToV2 = (smartconfigJson: Record<string, any>): boolean => {
|
||||
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
|
||||
const releaseConfig = ensureObject(cliConfig, "release");
|
||||
|
||||
let migrated = false;
|
||||
const targets = ensureObject(releaseConfig, "targets");
|
||||
const shipzoneConfig = smartconfigJson["@ship.zone/szci"];
|
||||
|
||||
if (isPlainObject(releaseConfig.git) && !isPlainObject(targets.git)) {
|
||||
targets.git = releaseConfig.git;
|
||||
delete releaseConfig.git;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (isPlainObject(releaseConfig.npm) && !isPlainObject(targets.npm)) {
|
||||
targets.npm = releaseConfig.npm;
|
||||
delete releaseConfig.npm;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (isPlainObject(releaseConfig.docker) && !isPlainObject(targets.docker)) {
|
||||
targets.docker = releaseConfig.docker;
|
||||
delete releaseConfig.docker;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (Array.isArray(releaseConfig.registries)) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
if (!Array.isArray(npmTarget.registries)) {
|
||||
npmTarget.registries = releaseConfig.registries;
|
||||
}
|
||||
delete releaseConfig.registries;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (releaseConfig.accessLevel) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
if (!npmTarget.accessLevel) {
|
||||
npmTarget.accessLevel = releaseConfig.accessLevel;
|
||||
}
|
||||
delete releaseConfig.accessLevel;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (isPlainObject(shipzoneConfig)) {
|
||||
if (shipzoneConfig.npmAccessLevel) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
if (!npmTarget.accessLevel) {
|
||||
npmTarget.accessLevel = shipzoneConfig.npmAccessLevel;
|
||||
}
|
||||
delete shipzoneConfig.npmAccessLevel;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (shipzoneConfig.npmRegistryUrl) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
const registry = normalizeRegistryUrl(shipzoneConfig.npmRegistryUrl);
|
||||
const registries = Array.isArray(npmTarget.registries) ? npmTarget.registries : [];
|
||||
if (!registries.includes(registry)) {
|
||||
registries.push(registry);
|
||||
}
|
||||
npmTarget.registries = registries;
|
||||
delete shipzoneConfig.npmRegistryUrl;
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(releaseConfig.steps)) {
|
||||
const steps = releaseConfig.steps as string[];
|
||||
const preflight = ensureObject(releaseConfig, "preflight");
|
||||
if (steps.includes("test") && preflight.test === undefined) {
|
||||
preflight.test = true;
|
||||
}
|
||||
if (steps.includes("build") && preflight.build === undefined) {
|
||||
preflight.build = true;
|
||||
}
|
||||
if (steps.includes("push")) {
|
||||
const gitTarget = ensureObject(targets, "git");
|
||||
if (gitTarget.enabled === undefined) {
|
||||
gitTarget.enabled = true;
|
||||
}
|
||||
}
|
||||
if (steps.includes("publishNpm")) {
|
||||
const npmTarget = ensureObject(targets, "npm");
|
||||
if (npmTarget.enabled === undefined) {
|
||||
npmTarget.enabled = true;
|
||||
}
|
||||
}
|
||||
if (steps.includes("publishDocker")) {
|
||||
const dockerTarget = ensureObject(targets, "docker");
|
||||
if (dockerTarget.enabled === undefined) {
|
||||
dockerTarget.enabled = true;
|
||||
}
|
||||
}
|
||||
delete releaseConfig.steps;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (releaseConfig.changelog) {
|
||||
delete releaseConfig.changelog;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
cliConfig.schemaVersion = 2;
|
||||
return migrated || true;
|
||||
};
|
||||
|
||||
const normalizeRegistryUrl = (url: string): string => {
|
||||
let normalizedUrl = url.trim();
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
return normalizedUrl.endsWith("/") ? normalizedUrl.slice(0, -1) : normalizedUrl;
|
||||
};
|
||||
|
||||
export const migrateSmartconfigData = (
|
||||
smartconfigJson: Record<string, any>,
|
||||
targetVersion = CURRENT_GITZONE_CLI_SCHEMA_VERSION,
|
||||
): ISmartconfigMigrationResult => {
|
||||
let migrated = false;
|
||||
migrated = migrateNamespaceKeys(smartconfigJson) || migrated;
|
||||
|
||||
const cliConfig = ensureObject(smartconfigJson, CLI_NAMESPACE);
|
||||
const fromVersion = typeof cliConfig.schemaVersion === "number" ? cliConfig.schemaVersion : 1;
|
||||
let currentVersion = fromVersion;
|
||||
|
||||
if (currentVersion < 2 && targetVersion >= 2) {
|
||||
migrated = migrateToV2(smartconfigJson) || migrated;
|
||||
currentVersion = 2;
|
||||
}
|
||||
|
||||
if (targetVersion === CURRENT_GITZONE_CLI_SCHEMA_VERSION && cliConfig.schemaVersion !== targetVersion) {
|
||||
cliConfig.schemaVersion = targetVersion;
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
migrated,
|
||||
fromVersion,
|
||||
toVersion: Math.min(targetVersion, CURRENT_GITZONE_CLI_SCHEMA_VERSION),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,387 @@
|
||||
import { getCliConfigValue } from "./helpers.smartconfig.js";
|
||||
|
||||
export type TConfirmationMode = "prompt" | "auto" | "plan";
|
||||
|
||||
export type TCommitStep =
|
||||
| "format"
|
||||
| "analyze"
|
||||
| "test"
|
||||
| "build"
|
||||
| "changelog"
|
||||
| "commit"
|
||||
| "push";
|
||||
|
||||
export type TReleaseTarget = "git" | "npm" | "docker";
|
||||
|
||||
export interface ICommitWorkflowConfig {
|
||||
confirmation?: TConfirmationMode;
|
||||
staging?: "all";
|
||||
steps?: TCommitStep[];
|
||||
alwaysTest?: boolean;
|
||||
alwaysBuild?: boolean;
|
||||
analyze?: {
|
||||
provider?: "ai";
|
||||
requireConfirmationFor?: string[];
|
||||
};
|
||||
test?: {
|
||||
command?: string;
|
||||
};
|
||||
build?: {
|
||||
command?: string;
|
||||
verifyCleanTree?: boolean;
|
||||
};
|
||||
push?: {
|
||||
remote?: string;
|
||||
followTags?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReleaseGitTargetConfig {
|
||||
enabled?: boolean;
|
||||
remote?: string;
|
||||
pushBranch?: boolean;
|
||||
pushTags?: boolean;
|
||||
}
|
||||
|
||||
export interface IReleaseNpmTargetConfig {
|
||||
enabled?: boolean;
|
||||
registries?: string[];
|
||||
accessLevel?: "public" | "private";
|
||||
alreadyPublished?: "success" | "error";
|
||||
}
|
||||
|
||||
export interface IReleaseDockerTargetConfig {
|
||||
enabled?: boolean;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
export interface IReleaseWorkflowConfig {
|
||||
confirmation?: TConfirmationMode;
|
||||
version?: {
|
||||
strategy?: "semver";
|
||||
source?: "pendingChangelog" | "manual";
|
||||
};
|
||||
preflight?: {
|
||||
requireCleanTree?: boolean;
|
||||
test?: boolean;
|
||||
build?: boolean;
|
||||
testCommand?: string;
|
||||
buildCommand?: string;
|
||||
};
|
||||
targets?: {
|
||||
git?: IReleaseGitTargetConfig;
|
||||
npm?: IReleaseNpmTargetConfig;
|
||||
docker?: IReleaseDockerTargetConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IResolvedCommitWorkflow {
|
||||
confirmation: TConfirmationMode;
|
||||
steps: TCommitStep[];
|
||||
staging: "all";
|
||||
testCommand: string;
|
||||
buildCommand: string;
|
||||
changelogFile: "changelog.md";
|
||||
changelogSection: "Pending";
|
||||
pushRemote: string;
|
||||
pushFollowTags: boolean;
|
||||
releaseFlagRequested: boolean;
|
||||
}
|
||||
|
||||
export interface IResolvedReleaseWorkflow {
|
||||
confirmation: TConfirmationMode;
|
||||
plan: string[];
|
||||
targets: TReleaseTarget[];
|
||||
requireCleanTree: boolean;
|
||||
runTests: boolean;
|
||||
runBuild: boolean;
|
||||
testCommand: string;
|
||||
buildCommand: string;
|
||||
changelogFile: "changelog.md";
|
||||
changelogPendingSection: "Pending";
|
||||
changelogVersionHeading: "## {{date}} - {{version}}";
|
||||
gitEnabled: boolean;
|
||||
gitRemote: string;
|
||||
pushBranch: boolean;
|
||||
pushTags: boolean;
|
||||
npmEnabled: boolean;
|
||||
npmRegistries: string[];
|
||||
npmAccessLevel: "public" | "private";
|
||||
npmAlreadyPublished: "success" | "error";
|
||||
dockerEnabled: boolean;
|
||||
dockerImages: string[];
|
||||
}
|
||||
|
||||
interface ICliWorkflowConfig {
|
||||
commit?: ICommitWorkflowConfig;
|
||||
release?: IReleaseWorkflowConfig;
|
||||
}
|
||||
|
||||
const commitFlagToStep: Record<string, TCommitStep | undefined> = {
|
||||
f: "format",
|
||||
t: "test",
|
||||
b: "build",
|
||||
p: "push",
|
||||
};
|
||||
|
||||
const unique = <T>(items: T[]): T[] => {
|
||||
const result: T[] = [];
|
||||
for (const item of items) {
|
||||
if (!result.includes(item)) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const normalizeConfirmation = (
|
||||
value: unknown,
|
||||
fallback: TConfirmationMode,
|
||||
): TConfirmationMode => {
|
||||
if (value === "prompt" || value === "auto" || value === "plan") {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
const normalizeRegistryUrl = (url: string): string => {
|
||||
let normalizedUrl = url.trim();
|
||||
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
|
||||
normalizedUrl = `https://${normalizedUrl}`;
|
||||
}
|
||||
return normalizedUrl.endsWith("/") ? normalizedUrl.slice(0, -1) : normalizedUrl;
|
||||
};
|
||||
|
||||
const isDisabled = (argvArg: any, ...keys: string[]): boolean => {
|
||||
return keys.some((key) => argvArg[key] === false || argvArg[`no-${key}`] || argvArg[`no${key[0].toUpperCase()}${key.slice(1)}`]);
|
||||
};
|
||||
|
||||
const readCliWorkflowConfig = async (): Promise<ICliWorkflowConfig> => {
|
||||
return await getCliConfigValue<ICliWorkflowConfig>("", {});
|
||||
};
|
||||
|
||||
const getOrderedArgsAfterCommand = (commandName: string): string[] => {
|
||||
const rawArgs = process.argv.slice(2);
|
||||
const commandIndex = rawArgs.indexOf(commandName);
|
||||
if (commandIndex === -1) {
|
||||
return rawArgs;
|
||||
}
|
||||
return rawArgs.slice(commandIndex + 1);
|
||||
};
|
||||
|
||||
const getOrderedShortFlags = (commandName: string): string[] => {
|
||||
const orderedFlags: string[] = [];
|
||||
for (const arg of getOrderedArgsAfterCommand(commandName)) {
|
||||
if (arg === "--") {
|
||||
break;
|
||||
}
|
||||
if (arg.startsWith("--")) {
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-") && arg.length > 1) {
|
||||
orderedFlags.push(...arg.slice(1).split(""));
|
||||
}
|
||||
}
|
||||
return orderedFlags;
|
||||
};
|
||||
|
||||
const hasExplicitCommitWorkflowFlags = (argvArg: any): boolean => {
|
||||
return Boolean(
|
||||
argvArg.f ||
|
||||
argvArg.format ||
|
||||
argvArg.t ||
|
||||
argvArg.test ||
|
||||
argvArg.b ||
|
||||
argvArg.build ||
|
||||
argvArg.p ||
|
||||
argvArg.push,
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeCommitSteps = (rawSteps: TCommitStep[]): TCommitStep[] => {
|
||||
const steps = unique(rawSteps.filter(Boolean));
|
||||
const pushRequested = steps.includes("push");
|
||||
const prePushSteps = steps.filter((step) => step !== "push");
|
||||
|
||||
if (!prePushSteps.includes("analyze")) {
|
||||
prePushSteps.unshift("analyze");
|
||||
}
|
||||
|
||||
if (!prePushSteps.includes("changelog")) {
|
||||
const commitIndex = prePushSteps.indexOf("commit");
|
||||
if (commitIndex === -1) {
|
||||
prePushSteps.push("changelog");
|
||||
} else {
|
||||
prePushSteps.splice(commitIndex, 0, "changelog");
|
||||
}
|
||||
}
|
||||
|
||||
if (!prePushSteps.includes("commit")) {
|
||||
prePushSteps.push("commit");
|
||||
}
|
||||
|
||||
const analyzeIndex = prePushSteps.indexOf("analyze");
|
||||
const commitIndex = prePushSteps.indexOf("commit");
|
||||
if (analyzeIndex > commitIndex) {
|
||||
throw new Error("Commit workflow requires analyze before commit.");
|
||||
}
|
||||
|
||||
const changelogIndex = prePushSteps.indexOf("changelog");
|
||||
if (changelogIndex === -1 || changelogIndex > commitIndex) {
|
||||
throw new Error("Commit workflow requires changelog before commit.");
|
||||
}
|
||||
|
||||
return pushRequested ? [...prePushSteps, "push"] : prePushSteps;
|
||||
};
|
||||
|
||||
const getTargetOverride = (argvArg: any): TReleaseTarget[] | undefined => {
|
||||
const validTargets: TReleaseTarget[] = ["git", "npm", "docker"];
|
||||
const rawTargets = argvArg.target || argvArg.targets;
|
||||
if (typeof rawTargets === "string") {
|
||||
return rawTargets
|
||||
.split(",")
|
||||
.map((target) => target.trim())
|
||||
.filter((target): target is TReleaseTarget => validTargets.includes(target as TReleaseTarget));
|
||||
}
|
||||
|
||||
const targets: TReleaseTarget[] = [];
|
||||
if (argvArg.git || argvArg.p || argvArg.push) targets.push("git");
|
||||
if (argvArg.npm) targets.push("npm");
|
||||
if (argvArg.docker) targets.push("docker");
|
||||
return targets.length > 0 ? targets : undefined;
|
||||
};
|
||||
|
||||
const buildReleasePlan = (options: {
|
||||
requireCleanTree: boolean;
|
||||
runTests: boolean;
|
||||
runBuild: boolean;
|
||||
targets: TReleaseTarget[];
|
||||
}): string[] => {
|
||||
const plan: string[] = [];
|
||||
if (options.requireCleanTree) plan.push("preflight.cleanTree");
|
||||
if (options.runTests) plan.push("preflight.test");
|
||||
plan.push("core.version", "core.changelog", "core.commit", "core.tag");
|
||||
if (options.runBuild) plan.push("core.build");
|
||||
for (const target of options.targets) {
|
||||
plan.push(`target.${target}`);
|
||||
}
|
||||
return plan;
|
||||
};
|
||||
|
||||
export const resolveCommitWorkflow = async (argvArg: any): Promise<IResolvedCommitWorkflow> => {
|
||||
const cliConfig = await readCliWorkflowConfig();
|
||||
const commitConfig = cliConfig.commit || {};
|
||||
const releaseFlagRequested = Boolean(argvArg.r || argvArg.release);
|
||||
|
||||
let confirmation = normalizeConfirmation(commitConfig.confirmation, "prompt");
|
||||
if (argvArg.plan) {
|
||||
confirmation = "plan";
|
||||
} else if (argvArg.y || argvArg.yes) {
|
||||
confirmation = "auto";
|
||||
}
|
||||
|
||||
let rawSteps: TCommitStep[];
|
||||
if (hasExplicitCommitWorkflowFlags(argvArg)) {
|
||||
const orderedFlags = getOrderedShortFlags("commit");
|
||||
rawSteps = ["analyze"];
|
||||
for (const shortFlag of orderedFlags) {
|
||||
const step = commitFlagToStep[shortFlag];
|
||||
if (step) {
|
||||
rawSteps.push(step);
|
||||
}
|
||||
}
|
||||
if (argvArg.format && !rawSteps.includes("format")) rawSteps.push("format");
|
||||
if (argvArg.test && !rawSteps.includes("test")) rawSteps.push("test");
|
||||
if (argvArg.build && !rawSteps.includes("build")) rawSteps.push("build");
|
||||
if (argvArg.push && !rawSteps.includes("push")) rawSteps.push("push");
|
||||
rawSteps.push("changelog");
|
||||
rawSteps.push("commit");
|
||||
} else if (Array.isArray(commitConfig.steps) && commitConfig.steps.length > 0) {
|
||||
rawSteps = commitConfig.steps;
|
||||
} else {
|
||||
rawSteps = ["analyze"];
|
||||
if (commitConfig.alwaysTest) rawSteps.push("test");
|
||||
if (commitConfig.alwaysBuild) rawSteps.push("build");
|
||||
rawSteps.push("changelog");
|
||||
rawSteps.push("commit");
|
||||
}
|
||||
|
||||
return {
|
||||
confirmation,
|
||||
steps: normalizeCommitSteps(rawSteps),
|
||||
staging: commitConfig.staging || "all",
|
||||
testCommand: commitConfig.test?.command || "pnpm test",
|
||||
buildCommand: commitConfig.build?.command || "pnpm build",
|
||||
changelogFile: "changelog.md",
|
||||
changelogSection: "Pending",
|
||||
pushRemote: commitConfig.push?.remote || "origin",
|
||||
pushFollowTags: commitConfig.push?.followTags || false,
|
||||
releaseFlagRequested,
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveReleaseWorkflow = async (argvArg: any): Promise<IResolvedReleaseWorkflow> => {
|
||||
const cliConfig = await readCliWorkflowConfig();
|
||||
const releaseConfig = cliConfig.release || {};
|
||||
const targetConfig = releaseConfig.targets || {};
|
||||
const gitConfig = targetConfig.git || {};
|
||||
const npmConfig = targetConfig.npm || {};
|
||||
const dockerConfig = targetConfig.docker || {};
|
||||
const npmRegistries = (npmConfig.registries || []).map(normalizeRegistryUrl);
|
||||
const npmEnabled = npmConfig.enabled ?? npmRegistries.length > 0;
|
||||
const gitEnabled = gitConfig.enabled ?? true;
|
||||
const dockerEnabled = dockerConfig.enabled ?? false;
|
||||
|
||||
let confirmation = normalizeConfirmation(releaseConfig.confirmation, "prompt");
|
||||
if (argvArg.plan) {
|
||||
confirmation = "plan";
|
||||
} else if (argvArg.y || argvArg.yes) {
|
||||
confirmation = "auto";
|
||||
}
|
||||
|
||||
let requireCleanTree = releaseConfig.preflight?.requireCleanTree ?? true;
|
||||
let runTests = releaseConfig.preflight?.test ?? false;
|
||||
let runBuild = releaseConfig.preflight?.build ?? true;
|
||||
if (argvArg.t || argvArg.test) runTests = true;
|
||||
if (argvArg.b || argvArg.build) runBuild = true;
|
||||
if (isDisabled(argvArg, "test")) runTests = false;
|
||||
if (isDisabled(argvArg, "build")) runBuild = false;
|
||||
if (isDisabled(argvArg, "preflight")) requireCleanTree = false;
|
||||
|
||||
const configuredTargets: TReleaseTarget[] = [];
|
||||
if (gitEnabled) configuredTargets.push("git");
|
||||
if (npmEnabled) configuredTargets.push("npm");
|
||||
if (dockerEnabled) configuredTargets.push("docker");
|
||||
let targets = getTargetOverride(argvArg) || configuredTargets;
|
||||
if (isDisabled(argvArg, "git", "push")) {
|
||||
targets = targets.filter((target) => target !== "git");
|
||||
}
|
||||
if (isDisabled(argvArg, "publish")) {
|
||||
targets = targets.filter((target) => target === "git");
|
||||
}
|
||||
targets = unique(targets);
|
||||
|
||||
return {
|
||||
confirmation,
|
||||
plan: buildReleasePlan({ requireCleanTree, runTests, runBuild, targets }),
|
||||
targets,
|
||||
requireCleanTree,
|
||||
runTests,
|
||||
runBuild,
|
||||
testCommand: releaseConfig.preflight?.testCommand || "pnpm test",
|
||||
buildCommand: releaseConfig.preflight?.buildCommand || "pnpm build",
|
||||
changelogFile: "changelog.md",
|
||||
changelogPendingSection: "Pending",
|
||||
changelogVersionHeading: "## {{date}} - {{version}}",
|
||||
gitEnabled,
|
||||
gitRemote: gitConfig.remote || "origin",
|
||||
pushBranch: gitConfig.pushBranch ?? true,
|
||||
pushTags: gitConfig.pushTags ?? true,
|
||||
npmEnabled,
|
||||
npmRegistries,
|
||||
npmAccessLevel: npmConfig.accessLevel || "public",
|
||||
npmAlreadyPublished: npmConfig.alreadyPublished || "success",
|
||||
dockerEnabled,
|
||||
dockerImages: dockerConfig.images || [],
|
||||
};
|
||||
};
|
||||
+249
-451
@@ -3,15 +3,11 @@
|
||||
import * as plugins from "./mod.plugins.js";
|
||||
import * as paths from "../paths.js";
|
||||
import { logger } from "../gitzone.logging.js";
|
||||
import * as helpers from "./mod.helpers.js";
|
||||
import * as ui from "./mod.ui.js";
|
||||
import { ReleaseConfig } from "../mod_config/classes.releaseconfig.js";
|
||||
import type { ICliMode } from "../helpers.climode.js";
|
||||
import {
|
||||
getCliMode,
|
||||
printJson,
|
||||
runWithSuppressedOutput,
|
||||
} from "../helpers.climode.js";
|
||||
import { getCliMode, printJson, runWithSuppressedOutput } from "../helpers.climode.js";
|
||||
import { appendPendingChangelogEntry } from "../helpers.changelog.js";
|
||||
import { resolveCommitWorkflow, type IResolvedCommitWorkflow } from "../helpers.workflow.js";
|
||||
|
||||
export const run = async (argvArg: any) => {
|
||||
const mode = await getCliMode(argvArg);
|
||||
@@ -36,431 +32,247 @@ export const run = async (argvArg: any) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read commit config from .smartconfig.json
|
||||
const smartconfigInstance = new plugins.smartconfig.Smartconfig();
|
||||
const gitzoneConfig = smartconfigInstance.dataFor<{
|
||||
commit?: {
|
||||
alwaysTest?: boolean;
|
||||
alwaysBuild?: boolean;
|
||||
};
|
||||
}>("@git.zone/cli", {});
|
||||
const commitConfig = gitzoneConfig.commit || {};
|
||||
|
||||
// Check flags and merge with config options
|
||||
const wantsRelease = !!(argvArg.r || argvArg.release);
|
||||
const wantsTest = !!(argvArg.t || argvArg.test || commitConfig.alwaysTest);
|
||||
const wantsBuild = !!(argvArg.b || argvArg.build || commitConfig.alwaysBuild);
|
||||
let releaseConfig: ReleaseConfig | null = null;
|
||||
|
||||
if (wantsRelease) {
|
||||
releaseConfig = await ReleaseConfig.fromCwd();
|
||||
if (!releaseConfig.hasRegistries()) {
|
||||
logger.log("error", "No release registries configured.");
|
||||
console.log("");
|
||||
console.log(
|
||||
" Run `gitzone config add <registry-url>` to add registries.",
|
||||
);
|
||||
console.log("");
|
||||
process.exit(1);
|
||||
}
|
||||
const workflow = await resolveCommitWorkflow(argvArg);
|
||||
if (workflow.releaseFlagRequested) {
|
||||
logger.log(
|
||||
"warn",
|
||||
"`gitzone commit -r` is deprecated and no longer releases. Use `gitzone release` after committing.",
|
||||
);
|
||||
}
|
||||
|
||||
// Print execution plan at the start
|
||||
ui.printExecutionPlan({
|
||||
autoAccept: !!(argvArg.y || argvArg.yes),
|
||||
push: !!(argvArg.p || argvArg.push),
|
||||
test: wantsTest,
|
||||
build: wantsBuild,
|
||||
release: wantsRelease,
|
||||
format: !!argvArg.format,
|
||||
registries: releaseConfig?.getRegistries(),
|
||||
});
|
||||
|
||||
if (argvArg.format) {
|
||||
const formatMod = await import("../mod_format/index.js");
|
||||
await formatMod.run();
|
||||
printCommitExecutionPlan(workflow);
|
||||
if (workflow.confirmation === "plan") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run tests early to fail fast before analysis
|
||||
if (wantsTest) {
|
||||
ui.printHeader("🧪 Running tests...");
|
||||
const smartshellForTest = new plugins.smartshell.Smartshell({
|
||||
executor: "bash",
|
||||
sourceFilePaths: [],
|
||||
});
|
||||
const testResult = await smartshellForTest.exec("pnpm test");
|
||||
if (testResult.exitCode !== 0) {
|
||||
logger.log("error", "Tests failed. Aborting commit.");
|
||||
process.exit(1);
|
||||
}
|
||||
logger.log("success", "All tests passed.");
|
||||
}
|
||||
|
||||
ui.printHeader("🔍 Analyzing repository changes...");
|
||||
|
||||
const aidoc = new plugins.tsdoc.AiDoc();
|
||||
await aidoc.start();
|
||||
|
||||
const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd);
|
||||
|
||||
await aidoc.stop();
|
||||
|
||||
ui.printRecommendation({
|
||||
recommendedNextVersion: nextCommitObject.recommendedNextVersion,
|
||||
recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel,
|
||||
recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope,
|
||||
recommendedNextVersionMessage:
|
||||
nextCommitObject.recommendedNextVersionMessage,
|
||||
});
|
||||
|
||||
let answerBucket: plugins.smartinteract.AnswerBucket;
|
||||
|
||||
// Check if -y/--yes flag is set AND version is not a breaking change
|
||||
// Breaking changes (major version bumps) always require manual confirmation
|
||||
const isBreakingChange =
|
||||
nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE";
|
||||
const canAutoAccept = (argvArg.y || argvArg.yes) && !isBreakingChange;
|
||||
|
||||
if (canAutoAccept) {
|
||||
// Auto-mode: create AnswerBucket programmatically
|
||||
logger.log("info", "✓ Auto-accepting AI recommendations (--yes flag)");
|
||||
|
||||
answerBucket = new plugins.smartinteract.AnswerBucket();
|
||||
answerBucket.addAnswer({
|
||||
name: "commitType",
|
||||
value: nextCommitObject.recommendedNextVersionLevel,
|
||||
});
|
||||
answerBucket.addAnswer({
|
||||
name: "commitScope",
|
||||
value: nextCommitObject.recommendedNextVersionScope,
|
||||
});
|
||||
answerBucket.addAnswer({
|
||||
name: "commitDescription",
|
||||
value: nextCommitObject.recommendedNextVersionMessage,
|
||||
});
|
||||
answerBucket.addAnswer({
|
||||
name: "pushToOrigin",
|
||||
value: !!(argvArg.p || argvArg.push), // Only push if -p flag also provided
|
||||
});
|
||||
answerBucket.addAnswer({
|
||||
name: "createRelease",
|
||||
value: wantsRelease,
|
||||
});
|
||||
} else {
|
||||
// Warn if --yes was provided but we're requiring confirmation due to breaking change
|
||||
if (isBreakingChange && (argvArg.y || argvArg.yes)) {
|
||||
logger.log(
|
||||
"warn",
|
||||
"⚠️ BREAKING CHANGE detected - manual confirmation required",
|
||||
);
|
||||
}
|
||||
// Interactive mode: prompt user for input
|
||||
const commitInteract = new plugins.smartinteract.SmartInteract();
|
||||
commitInteract.addQuestions([
|
||||
{
|
||||
type: "list",
|
||||
name: `commitType`,
|
||||
message: `Choose TYPE of the commit:`,
|
||||
choices: [`fix`, `feat`, `BREAKING CHANGE`],
|
||||
default: nextCommitObject.recommendedNextVersionLevel,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: `commitScope`,
|
||||
message: `What is the SCOPE of the commit:`,
|
||||
default: nextCommitObject.recommendedNextVersionScope,
|
||||
},
|
||||
{
|
||||
type: `input`,
|
||||
name: `commitDescription`,
|
||||
message: `What is the DESCRIPTION of the commit?`,
|
||||
default: nextCommitObject.recommendedNextVersionMessage,
|
||||
},
|
||||
{
|
||||
type: "confirm",
|
||||
name: `pushToOrigin`,
|
||||
message: `Do you want to push this version now?`,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
type: "confirm",
|
||||
name: `createRelease`,
|
||||
message: `Do you want to publish to npm registries?`,
|
||||
default: wantsRelease,
|
||||
},
|
||||
]);
|
||||
answerBucket = await commitInteract.runQueue();
|
||||
}
|
||||
const commitString = createCommitStringFromAnswerBucket(answerBucket);
|
||||
const commitType = answerBucket.getAnswerFor("commitType");
|
||||
let commitVersionType: helpers.VersionType;
|
||||
switch (commitType) {
|
||||
case "fix":
|
||||
commitVersionType = "patch";
|
||||
break;
|
||||
case "feat":
|
||||
commitVersionType = "minor";
|
||||
break;
|
||||
case "BREAKING CHANGE":
|
||||
commitVersionType = "major";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported commit type: ${commitType}`);
|
||||
}
|
||||
|
||||
ui.printHeader("✨ Creating Semantic Commit");
|
||||
ui.printCommitMessage(commitString);
|
||||
const smartshellInstance = new plugins.smartshell.Smartshell({
|
||||
executor: "bash",
|
||||
sourceFilePaths: [],
|
||||
});
|
||||
|
||||
// Load release config if user wants to release (interactively selected)
|
||||
if (answerBucket.getAnswerFor("createRelease") && !releaseConfig) {
|
||||
releaseConfig = await ReleaseConfig.fromCwd();
|
||||
if (!releaseConfig.hasRegistries()) {
|
||||
logger.log("error", "No release registries configured.");
|
||||
console.log("");
|
||||
console.log(
|
||||
" Run `gitzone config add <registry-url>` to add registries.",
|
||||
);
|
||||
console.log("");
|
||||
process.exit(1);
|
||||
let nextCommitObject: any;
|
||||
let answerBucket: plugins.smartinteract.AnswerBucket | undefined;
|
||||
|
||||
for (const step of workflow.steps) {
|
||||
switch (step) {
|
||||
case "format":
|
||||
await runFormatStep();
|
||||
break;
|
||||
case "test":
|
||||
await runCommandStep(smartshellInstance, "Running tests", workflow.testCommand);
|
||||
break;
|
||||
case "build":
|
||||
await runCommandStep(smartshellInstance, "Running build", workflow.buildCommand);
|
||||
break;
|
||||
case "analyze":
|
||||
nextCommitObject = await runAnalyzeStep();
|
||||
answerBucket = await buildAnswerBucket(nextCommitObject, workflow, mode, argvArg);
|
||||
break;
|
||||
case "changelog":
|
||||
assertAnalysisComplete(answerBucket, nextCommitObject);
|
||||
await runChangelogStep(workflow, answerBucket!, nextCommitObject);
|
||||
break;
|
||||
case "commit":
|
||||
assertAnalysisComplete(answerBucket, nextCommitObject);
|
||||
await runCommitStep(smartshellInstance, answerBucket!);
|
||||
break;
|
||||
case "push":
|
||||
await runPushStep(smartshellInstance, workflow);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine total steps based on options
|
||||
// Note: test runs early (like format) so not counted in numbered steps
|
||||
const willPush =
|
||||
answerBucket.getAnswerFor("pushToOrigin") && !(process.env.CI === "true");
|
||||
const willRelease =
|
||||
answerBucket.getAnswerFor("createRelease") &&
|
||||
releaseConfig?.hasRegistries();
|
||||
let totalSteps = 5; // Base steps: commitinfo, changelog, staging, commit, version
|
||||
if (wantsBuild) totalSteps += 2; // build step + verification step
|
||||
if (willPush) totalSteps++;
|
||||
if (willRelease) totalSteps++;
|
||||
let currentStep = 0;
|
||||
|
||||
// Step 1: Baking commitinfo
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔧 Baking commit info into code",
|
||||
"in-progress",
|
||||
);
|
||||
const commitInfo = new plugins.commitinfo.CommitInfo(
|
||||
paths.cwd,
|
||||
commitVersionType,
|
||||
);
|
||||
await commitInfo.writeIntoPotentialDirs();
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔧 Baking commit info into code",
|
||||
"done",
|
||||
);
|
||||
|
||||
// Step 2: Writing changelog
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"📄 Generating changelog.md",
|
||||
"in-progress",
|
||||
);
|
||||
let changelog = nextCommitObject.changelog || "# Changelog\n";
|
||||
changelog = changelog.replaceAll(
|
||||
"{{nextVersion}}",
|
||||
(await commitInfo.getNextPlannedVersion()).versionString,
|
||||
);
|
||||
changelog = changelog.replaceAll(
|
||||
"{{nextVersionScope}}",
|
||||
`${await answerBucket.getAnswerFor("commitType")}(${await answerBucket.getAnswerFor("commitScope")})`,
|
||||
);
|
||||
changelog = changelog.replaceAll(
|
||||
"{{nextVersionMessage}}",
|
||||
nextCommitObject.recommendedNextVersionMessage,
|
||||
);
|
||||
if (nextCommitObject.recommendedNextVersionDetails?.length > 0) {
|
||||
changelog = changelog.replaceAll(
|
||||
"{{nextVersionDetails}}",
|
||||
"- " + nextCommitObject.recommendedNextVersionDetails.join("\n- "),
|
||||
);
|
||||
} else {
|
||||
changelog = changelog.replaceAll("\n{{nextVersionDetails}}", "");
|
||||
}
|
||||
|
||||
await plugins.smartfs
|
||||
.file(plugins.path.join(paths.cwd, `changelog.md`))
|
||||
.encoding("utf8")
|
||||
.write(changelog);
|
||||
ui.printStep(currentStep, totalSteps, "📄 Generating changelog.md", "done");
|
||||
|
||||
// Step 3: Staging files
|
||||
currentStep++;
|
||||
ui.printStep(currentStep, totalSteps, "📦 Staging files", "in-progress");
|
||||
await smartshellInstance.exec(`git add -A`);
|
||||
ui.printStep(currentStep, totalSteps, "📦 Staging files", "done");
|
||||
|
||||
// Step 4: Creating commit
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"💾 Creating git commit",
|
||||
"in-progress",
|
||||
);
|
||||
await smartshellInstance.exec(`git commit -m "${commitString}"`);
|
||||
ui.printStep(currentStep, totalSteps, "💾 Creating git commit", "done");
|
||||
|
||||
// Step 5: Bumping version
|
||||
currentStep++;
|
||||
const projectType = await helpers.detectProjectType();
|
||||
const newVersion = await helpers.bumpProjectVersion(
|
||||
projectType,
|
||||
commitVersionType,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
);
|
||||
|
||||
// Step 6: Run build (optional)
|
||||
if (wantsBuild) {
|
||||
currentStep++;
|
||||
ui.printStep(currentStep, totalSteps, "🔨 Running build", "in-progress");
|
||||
const buildResult = await smartshellInstance.exec("pnpm build");
|
||||
if (buildResult.exitCode !== 0) {
|
||||
ui.printStep(currentStep, totalSteps, "🔨 Running build", "error");
|
||||
logger.log("error", "Build failed. Aborting release.");
|
||||
process.exit(1);
|
||||
}
|
||||
ui.printStep(currentStep, totalSteps, "🔨 Running build", "done");
|
||||
|
||||
// Step 7: Verify no uncommitted changes
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔍 Verifying clean working tree",
|
||||
"in-progress",
|
||||
);
|
||||
const statusResult = await smartshellInstance.exec(
|
||||
"git status --porcelain",
|
||||
);
|
||||
if (statusResult.stdout.trim() !== "") {
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔍 Verifying clean working tree",
|
||||
"error",
|
||||
);
|
||||
logger.log(
|
||||
"error",
|
||||
"Build produced uncommitted changes. This usually means build output is not gitignored.",
|
||||
);
|
||||
logger.log("error", "Uncommitted files:");
|
||||
console.log(statusResult.stdout);
|
||||
logger.log(
|
||||
"error",
|
||||
"Aborting release. Please ensure build artifacts are in .gitignore",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
"🔍 Verifying clean working tree",
|
||||
"done",
|
||||
);
|
||||
}
|
||||
|
||||
// Step: Push to remote (optional)
|
||||
const currentBranch = await helpers.detectCurrentBranch();
|
||||
if (willPush) {
|
||||
currentStep++;
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`🚀 Pushing to origin/${currentBranch}`,
|
||||
"in-progress",
|
||||
);
|
||||
await smartshellInstance.exec(
|
||||
`git push origin ${currentBranch} --follow-tags`,
|
||||
);
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`🚀 Pushing to origin/${currentBranch}`,
|
||||
"done",
|
||||
);
|
||||
}
|
||||
|
||||
// Step 7: Publish to npm registries (optional)
|
||||
let releasedRegistries: string[] = [];
|
||||
if (willRelease && releaseConfig) {
|
||||
currentStep++;
|
||||
const registries = releaseConfig.getRegistries();
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
|
||||
"in-progress",
|
||||
);
|
||||
|
||||
const accessLevel = releaseConfig.getAccessLevel();
|
||||
for (const registry of registries) {
|
||||
try {
|
||||
await smartshellInstance.exec(
|
||||
`npm publish --registry=${registry} --access=${accessLevel}`,
|
||||
);
|
||||
releasedRegistries.push(registry);
|
||||
} catch (error) {
|
||||
logger.log("error", `Failed to publish to ${registry}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (releasedRegistries.length === registries.length) {
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
|
||||
"done",
|
||||
);
|
||||
} else {
|
||||
ui.printStep(
|
||||
currentStep,
|
||||
totalSteps,
|
||||
`📦 Publishing to ${registries.length} registr${registries.length === 1 ? "y" : "ies"}`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(""); // Add spacing before summary
|
||||
|
||||
// Get commit SHA for summary
|
||||
const commitShaResult = await smartshellInstance.exec(
|
||||
"git rev-parse --short HEAD",
|
||||
);
|
||||
const commitSha = commitShaResult.stdout.trim();
|
||||
|
||||
// Print final summary
|
||||
const commitShaResult = await smartshellInstance.exec("git rev-parse --short HEAD");
|
||||
const currentBranch = await detectCurrentBranch(smartshellInstance);
|
||||
ui.printSummary({
|
||||
projectType,
|
||||
projectType: "source",
|
||||
branch: currentBranch,
|
||||
commitType: answerBucket.getAnswerFor("commitType"),
|
||||
commitScope: answerBucket.getAnswerFor("commitScope"),
|
||||
commitMessage: answerBucket.getAnswerFor("commitDescription"),
|
||||
newVersion: newVersion,
|
||||
commitSha: commitSha,
|
||||
pushed: willPush,
|
||||
released: releasedRegistries.length > 0,
|
||||
releasedRegistries:
|
||||
releasedRegistries.length > 0 ? releasedRegistries : undefined,
|
||||
commitType: answerBucket!.getAnswerFor("commitType"),
|
||||
commitScope: answerBucket!.getAnswerFor("commitScope"),
|
||||
commitMessage: answerBucket!.getAnswerFor("commitDescription"),
|
||||
commitSha: commitShaResult.stdout.trim(),
|
||||
pushed: workflow.steps.includes("push"),
|
||||
});
|
||||
};
|
||||
|
||||
async function runFormatStep(): Promise<void> {
|
||||
ui.printHeader("Formatting project files");
|
||||
const formatMod = await import("../mod_format/index.js");
|
||||
await formatMod.run({ write: true, yes: true, interactive: false });
|
||||
}
|
||||
|
||||
async function runCommandStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
label: string,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
ui.printHeader(label);
|
||||
const result = await smartshellInstance.exec(command);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", `${label} failed. Aborting commit.`);
|
||||
process.exit(1);
|
||||
}
|
||||
logger.log("success", `${label} passed.`);
|
||||
}
|
||||
|
||||
async function runAnalyzeStep(): Promise<any> {
|
||||
ui.printHeader("Analyzing repository changes");
|
||||
const aidoc = new plugins.tsdoc.AiDoc();
|
||||
await aidoc.start();
|
||||
try {
|
||||
const nextCommitObject = await aidoc.buildNextCommitObject(paths.cwd);
|
||||
ui.printRecommendation({
|
||||
recommendedNextVersion: nextCommitObject.recommendedNextVersion,
|
||||
recommendedNextVersionLevel: nextCommitObject.recommendedNextVersionLevel,
|
||||
recommendedNextVersionScope: nextCommitObject.recommendedNextVersionScope,
|
||||
recommendedNextVersionMessage: nextCommitObject.recommendedNextVersionMessage,
|
||||
});
|
||||
return nextCommitObject;
|
||||
} finally {
|
||||
await aidoc.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function buildAnswerBucket(
|
||||
nextCommitObject: any,
|
||||
workflow: IResolvedCommitWorkflow,
|
||||
mode: ICliMode,
|
||||
argvArg: any,
|
||||
): Promise<plugins.smartinteract.AnswerBucket> {
|
||||
const isBreakingChange = nextCommitObject.recommendedNextVersionLevel === "BREAKING CHANGE";
|
||||
const canAutoAccept = workflow.confirmation === "auto" && !isBreakingChange;
|
||||
|
||||
if (canAutoAccept) {
|
||||
logger.log("info", "Auto-accepting AI recommendations");
|
||||
return createAnswerBucket({
|
||||
commitType: nextCommitObject.recommendedNextVersionLevel,
|
||||
commitScope: nextCommitObject.recommendedNextVersionScope,
|
||||
commitDescription: nextCommitObject.recommendedNextVersionMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBreakingChange && (workflow.confirmation === "auto" || argvArg.y || argvArg.yes)) {
|
||||
logger.log("warn", "BREAKING CHANGE detected - manual confirmation required");
|
||||
}
|
||||
|
||||
if (!mode.interactive) {
|
||||
throw new Error("Commit confirmation requires an interactive terminal. Use `-y` or set commit.confirmation to `auto`.");
|
||||
}
|
||||
|
||||
const commitInteract = new plugins.smartinteract.SmartInteract();
|
||||
commitInteract.addQuestions([
|
||||
{
|
||||
type: "list",
|
||||
name: "commitType",
|
||||
message: "Choose TYPE of the commit:",
|
||||
choices: ["fix", "feat", "BREAKING CHANGE"],
|
||||
default: nextCommitObject.recommendedNextVersionLevel,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: "commitScope",
|
||||
message: "What is the SCOPE of the commit:",
|
||||
default: nextCommitObject.recommendedNextVersionScope,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
name: "commitDescription",
|
||||
message: "What is the DESCRIPTION of the commit?",
|
||||
default: nextCommitObject.recommendedNextVersionMessage,
|
||||
},
|
||||
]);
|
||||
return await commitInteract.runQueue();
|
||||
}
|
||||
|
||||
function createAnswerBucket(answers: {
|
||||
commitType: string;
|
||||
commitScope: string;
|
||||
commitDescription: string;
|
||||
}): plugins.smartinteract.AnswerBucket {
|
||||
const answerBucket = new plugins.smartinteract.AnswerBucket();
|
||||
for (const [name, value] of Object.entries(answers)) {
|
||||
answerBucket.addAnswer({ name, value });
|
||||
}
|
||||
return answerBucket;
|
||||
}
|
||||
|
||||
async function runChangelogStep(
|
||||
workflow: IResolvedCommitWorkflow,
|
||||
answerBucket: plugins.smartinteract.AnswerBucket,
|
||||
nextCommitObject: any,
|
||||
): Promise<void> {
|
||||
await appendPendingChangelogEntry(
|
||||
plugins.path.join(paths.cwd, workflow.changelogFile),
|
||||
workflow.changelogSection,
|
||||
{
|
||||
type: answerBucket.getAnswerFor("commitType"),
|
||||
scope: answerBucket.getAnswerFor("commitScope"),
|
||||
message: answerBucket.getAnswerFor("commitDescription"),
|
||||
details: nextCommitObject.recommendedNextVersionDetails || [],
|
||||
},
|
||||
);
|
||||
logger.log("success", `Updated ${workflow.changelogFile} pending section.`);
|
||||
}
|
||||
|
||||
async function runCommitStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
answerBucket: plugins.smartinteract.AnswerBucket,
|
||||
): Promise<void> {
|
||||
ui.printHeader("Creating Semantic Commit");
|
||||
const commitString = createCommitStringFromAnswerBucket(answerBucket);
|
||||
ui.printCommitMessage(commitString);
|
||||
await smartshellInstance.exec("git add -A");
|
||||
const result = await smartshellInstance.exec(`git commit -m ${shellQuote(commitString)}`);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", "git commit failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPushStep(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
workflow: IResolvedCommitWorkflow,
|
||||
): Promise<void> {
|
||||
const currentBranch = await detectCurrentBranch(smartshellInstance);
|
||||
const followTags = workflow.pushFollowTags ? " --follow-tags" : "";
|
||||
const result = await smartshellInstance.exec(
|
||||
`git push ${workflow.pushRemote} ${currentBranch}${followTags}`,
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
logger.log("error", "git push failed.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectCurrentBranch(
|
||||
smartshellInstance: plugins.smartshell.Smartshell,
|
||||
): Promise<string> {
|
||||
const branchResult = await smartshellInstance.exec("git branch --show-current");
|
||||
return branchResult.stdout.trim() || "master";
|
||||
}
|
||||
|
||||
function assertAnalysisComplete(
|
||||
answerBucket: plugins.smartinteract.AnswerBucket | undefined,
|
||||
nextCommitObject: any,
|
||||
): void {
|
||||
if (!answerBucket || !nextCommitObject) {
|
||||
throw new Error("Commit workflow requires analyze before changelog and commit steps.");
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function printCommitExecutionPlan(workflow: IResolvedCommitWorkflow): void {
|
||||
console.log("");
|
||||
console.log("gitzone commit - resolved workflow");
|
||||
console.log(`confirmation: ${workflow.confirmation}`);
|
||||
console.log(`steps: ${workflow.steps.join(" -> ")}`);
|
||||
console.log(`changelog: ${workflow.changelogFile}#${workflow.changelogSection}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
async function handleRecommend(mode: ICliMode): Promise<void> {
|
||||
const recommendationBuilder = async () => {
|
||||
const aidoc = new plugins.tsdoc.AiDoc();
|
||||
@@ -507,40 +319,27 @@ export function showHelp(mode?: ICliMode): void {
|
||||
printJson({
|
||||
command: "commit",
|
||||
usage: "gitzone commit [recommend] [options]",
|
||||
description:
|
||||
"Creates semantic commits or emits a read-only recommendation.",
|
||||
description: "Analyzes changes and creates one semantic source commit.",
|
||||
commands: [
|
||||
{
|
||||
name: "recommend",
|
||||
description:
|
||||
"Generate a commit recommendation without mutating the repository",
|
||||
description: "Generate a commit recommendation without mutating the repository",
|
||||
},
|
||||
],
|
||||
flags: [
|
||||
{ flag: "-y, --yes", description: "Auto-accept AI recommendations" },
|
||||
{ flag: "-p, --push", description: "Push to origin after commit" },
|
||||
{ flag: "-t, --test", description: "Run tests before the commit flow" },
|
||||
{
|
||||
flag: "-b, --build",
|
||||
description: "Run the build after the commit flow",
|
||||
},
|
||||
{
|
||||
flag: "-r, --release",
|
||||
description: "Publish to configured registries after push",
|
||||
},
|
||||
{
|
||||
flag: "--format",
|
||||
description: "Run gitzone format before committing",
|
||||
},
|
||||
{
|
||||
flag: "--json",
|
||||
description: "Emit JSON for `commit recommend` only",
|
||||
},
|
||||
{ flag: "-y, --yes", description: "Auto-accept safe AI recommendations" },
|
||||
{ flag: "-p, --push", description: "Push to origin after committing" },
|
||||
{ flag: "-t, --test", description: "Run tests as part of the commit workflow" },
|
||||
{ flag: "-b, --build", description: "Run build as part of the commit workflow" },
|
||||
{ flag: "-f, --format", description: "Run gitzone format before committing" },
|
||||
{ flag: "--plan", description: "Show resolved workflow without mutating files" },
|
||||
{ flag: "--json", description: "Emit JSON for `commit recommend` only" },
|
||||
],
|
||||
examples: [
|
||||
"gitzone commit recommend --json",
|
||||
"gitzone commit -y",
|
||||
"gitzone commit -ypbr",
|
||||
"gitzone commit -ytbp",
|
||||
"gitzone release",
|
||||
],
|
||||
});
|
||||
return;
|
||||
@@ -549,25 +348,24 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log("");
|
||||
console.log("Usage: gitzone commit [recommend] [options]");
|
||||
console.log("");
|
||||
console.log("Creates one semantic source commit. It does not version, tag, or publish.");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(
|
||||
" recommend Generate a commit recommendation without mutating the repository",
|
||||
);
|
||||
console.log(" recommend Generate a commit recommendation without mutating the repository");
|
||||
console.log("");
|
||||
console.log("Flags:");
|
||||
console.log(" -y, --yes Auto-accept AI recommendations");
|
||||
console.log(" -p, --push Push to origin after commit");
|
||||
console.log(" -t, --test Run tests before the commit flow");
|
||||
console.log(" -b, --build Run the build after the commit flow");
|
||||
console.log(
|
||||
" -r, --release Publish to configured registries after push",
|
||||
);
|
||||
console.log(" --format Run gitzone format before committing");
|
||||
console.log(" -y, --yes Auto-accept safe AI recommendations");
|
||||
console.log(" -p, --push Push after commit");
|
||||
console.log(" -t, --test Run tests in the configured order");
|
||||
console.log(" -b, --build Run build in the configured order");
|
||||
console.log(" -f, --format Run gitzone format before committing");
|
||||
console.log(" --plan Show resolved workflow without mutating files");
|
||||
console.log(" --json Emit JSON for `commit recommend` only");
|
||||
console.log("");
|
||||
console.log("Examples:");
|
||||
console.log(" gitzone commit recommend --json");
|
||||
console.log(" gitzone commit -y");
|
||||
console.log(" gitzone commit -ypbr");
|
||||
console.log(" gitzone commit -ytbp");
|
||||
console.log(" gitzone release");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function detectProjectType(): Promise<ProjectType> {
|
||||
* @param versionType Type of version bump
|
||||
* @returns New version string
|
||||
*/
|
||||
function calculateNewVersion(currentVersion: string, versionType: VersionType): string {
|
||||
export function calculateNewVersion(currentVersion: string, versionType: VersionType): string {
|
||||
const versionMatch = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)/);
|
||||
|
||||
if (!versionMatch) {
|
||||
@@ -95,7 +95,7 @@ function calculateNewVersion(currentVersion: string, versionType: VersionType):
|
||||
* @param projectType The project type to determine which file to read
|
||||
* @returns The current version string
|
||||
*/
|
||||
async function readCurrentVersion(projectType: ProjectType): Promise<string> {
|
||||
export async function readCurrentVersion(projectType: ProjectType): Promise<string> {
|
||||
if (projectType === 'npm' || projectType === 'both') {
|
||||
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
|
||||
const content = (await plugins.smartfs
|
||||
@@ -128,7 +128,7 @@ async function readCurrentVersion(projectType: ProjectType): Promise<string> {
|
||||
* @param filePath Path to the JSON file
|
||||
* @param newVersion The new version to write
|
||||
*/
|
||||
async function updateVersionFile(filePath: string, newVersion: string): Promise<void> {
|
||||
export async function updateVersionFile(filePath: string, newVersion: string): Promise<void> {
|
||||
const content = (await plugins.smartfs
|
||||
.file(filePath)
|
||||
.encoding('utf8')
|
||||
@@ -141,6 +141,30 @@ async function updateVersionFile(filePath: string, newVersion: string): Promise<
|
||||
.write(JSON.stringify(config, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates project version files without creating commits or tags.
|
||||
*/
|
||||
export async function updateProjectVersionFiles(
|
||||
projectType: ProjectType,
|
||||
newVersion: string,
|
||||
): Promise<string[]> {
|
||||
const filesToUpdate: string[] = [];
|
||||
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
|
||||
const denoJsonPath = plugins.path.join(paths.cwd, 'deno.json');
|
||||
|
||||
if (projectType === 'npm' || projectType === 'both') {
|
||||
await updateVersionFile(packageJsonPath, newVersion);
|
||||
filesToUpdate.push('package.json');
|
||||
}
|
||||
|
||||
if (projectType === 'deno' || projectType === 'both') {
|
||||
await updateVersionFile(denoJsonPath, newVersion);
|
||||
filesToUpdate.push('deno.json');
|
||||
}
|
||||
|
||||
return filesToUpdate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumps the project version based on project type
|
||||
* Handles npm-only, deno-only, and dual projects with unified logic
|
||||
@@ -182,19 +206,7 @@ export async function bumpProjectVersion(
|
||||
logger.log('info', `Bumping version: ${currentVersion} → ${newVersion}`);
|
||||
|
||||
// 3. Determine which files to update
|
||||
const filesToUpdate: string[] = [];
|
||||
const packageJsonPath = plugins.path.join(paths.cwd, 'package.json');
|
||||
const denoJsonPath = plugins.path.join(paths.cwd, 'deno.json');
|
||||
|
||||
if (projectType === 'npm' || projectType === 'both') {
|
||||
await updateVersionFile(packageJsonPath, newVersion);
|
||||
filesToUpdate.push('package.json');
|
||||
}
|
||||
|
||||
if (projectType === 'deno' || projectType === 'both') {
|
||||
await updateVersionFile(denoJsonPath, newVersion);
|
||||
filesToUpdate.push('deno.json');
|
||||
}
|
||||
const filesToUpdate = await updateProjectVersionFiles(projectType, newVersion);
|
||||
|
||||
// 4. Stage all updated files
|
||||
await smartshellInstance.exec(`git add ${filesToUpdate.join(' ')}`);
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ICommitSummary {
|
||||
commitType: string;
|
||||
commitScope: string;
|
||||
commitMessage: string;
|
||||
newVersion: string;
|
||||
newVersion?: string;
|
||||
commitSha?: string;
|
||||
pushed: boolean;
|
||||
repoUrl?: string;
|
||||
@@ -197,9 +197,14 @@ export function printSummary(summary: ICommitSummary): void {
|
||||
`Branch: 🌿 ${summary.branch}`,
|
||||
`Commit Type: ${getCommitTypeEmoji(summary.commitType)}`,
|
||||
`Scope: 📍 ${summary.commitScope}`,
|
||||
`New Version: 🏷️ v${summary.newVersion}`,
|
||||
];
|
||||
|
||||
if (summary.newVersion) {
|
||||
lines.push(`New Version: 🏷️ v${summary.newVersion}`);
|
||||
} else {
|
||||
lines.push(`Version: ⊘ Not bumped`);
|
||||
}
|
||||
|
||||
if (summary.commitSha) {
|
||||
lines.push(`Commit SHA: 📌 ${summary.commitSha}`);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as plugins from './mod.plugins.js';
|
||||
export interface ICommitConfig {
|
||||
alwaysTest: boolean;
|
||||
alwaysBuild: boolean;
|
||||
confirmation: 'prompt' | 'auto' | 'plan';
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,7 +17,7 @@ export class CommitConfig {
|
||||
|
||||
constructor(cwd: string = process.cwd()) {
|
||||
this.cwd = cwd;
|
||||
this.config = { alwaysTest: false, alwaysBuild: false };
|
||||
this.config = { alwaysTest: false, alwaysBuild: false, confirmation: 'prompt', steps: ['analyze', 'changelog', 'commit'] };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,9 +36,19 @@ export class CommitConfig {
|
||||
const smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd);
|
||||
const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
|
||||
|
||||
const alwaysTest = gitzoneConfig?.commit?.alwaysTest ?? false;
|
||||
const alwaysBuild = gitzoneConfig?.commit?.alwaysBuild ?? false;
|
||||
this.config = {
|
||||
alwaysTest: gitzoneConfig?.commit?.alwaysTest ?? false,
|
||||
alwaysBuild: gitzoneConfig?.commit?.alwaysBuild ?? false,
|
||||
alwaysTest,
|
||||
alwaysBuild,
|
||||
confirmation: gitzoneConfig?.commit?.confirmation ?? 'prompt',
|
||||
steps: gitzoneConfig?.commit?.steps || [
|
||||
'analyze',
|
||||
...(alwaysTest ? ['test'] : []),
|
||||
...(alwaysBuild ? ['build'] : []),
|
||||
'changelog',
|
||||
'commit',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,6 +78,8 @@ export class CommitConfig {
|
||||
// Update commit settings
|
||||
smartconfigData['@git.zone/cli'].commit.alwaysTest = this.config.alwaysTest;
|
||||
smartconfigData['@git.zone/cli'].commit.alwaysBuild = this.config.alwaysBuild;
|
||||
smartconfigData['@git.zone/cli'].commit.confirmation = this.config.confirmation;
|
||||
smartconfigData['@git.zone/cli'].commit.steps = this.config.steps;
|
||||
|
||||
// Write back to file
|
||||
await plugins.smartfs
|
||||
@@ -101,4 +115,20 @@ export class CommitConfig {
|
||||
public setAlwaysBuild(value: boolean): void {
|
||||
this.config.alwaysBuild = value;
|
||||
}
|
||||
|
||||
public getConfirmation(): 'prompt' | 'auto' | 'plan' {
|
||||
return this.config.confirmation;
|
||||
}
|
||||
|
||||
public setConfirmation(value: 'prompt' | 'auto' | 'plan'): void {
|
||||
this.config.confirmation = value;
|
||||
}
|
||||
|
||||
public getSteps(): string[] {
|
||||
return [...this.config.steps];
|
||||
}
|
||||
|
||||
public setSteps(steps: string[]): void {
|
||||
this.config.steps = [...steps];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +35,11 @@ export class ReleaseConfig {
|
||||
public async load(): Promise<void> {
|
||||
const smartconfigInstance = new plugins.smartconfig.Smartconfig(this.cwd);
|
||||
const gitzoneConfig = smartconfigInstance.dataFor<any>('@git.zone/cli', {});
|
||||
|
||||
// Also check szci for backward compatibility
|
||||
const szciConfig = smartconfigInstance.dataFor<any>('@ship.zone/szci', {});
|
||||
const npmTarget = gitzoneConfig?.release?.targets?.npm || {};
|
||||
|
||||
this.config = {
|
||||
registries: gitzoneConfig?.release?.registries || [],
|
||||
accessLevel: gitzoneConfig?.release?.accessLevel || szciConfig?.npmAccessLevel || 'public',
|
||||
registries: npmTarget.registries || [],
|
||||
accessLevel: npmTarget.accessLevel || 'public',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,9 +66,18 @@ export class ReleaseConfig {
|
||||
smartconfigData['@git.zone/cli'].release = {};
|
||||
}
|
||||
|
||||
if (!smartconfigData['@git.zone/cli'].release.targets) {
|
||||
smartconfigData['@git.zone/cli'].release.targets = {};
|
||||
}
|
||||
|
||||
if (!smartconfigData['@git.zone/cli'].release.targets.npm) {
|
||||
smartconfigData['@git.zone/cli'].release.targets.npm = {};
|
||||
}
|
||||
|
||||
// Update registries and accessLevel
|
||||
smartconfigData['@git.zone/cli'].release.registries = this.config.registries;
|
||||
smartconfigData['@git.zone/cli'].release.accessLevel = this.config.accessLevel;
|
||||
smartconfigData['@git.zone/cli'].release.targets.npm.enabled = this.config.registries.length > 0;
|
||||
smartconfigData['@git.zone/cli'].release.targets.npm.registries = this.config.registries;
|
||||
smartconfigData['@git.zone/cli'].release.targets.npm.accessLevel = this.config.accessLevel;
|
||||
|
||||
// Write back to file
|
||||
await plugins.smartfs
|
||||
|
||||
+89
-28
@@ -1,4 +1,4 @@
|
||||
// gitzone config - manage release registry configuration
|
||||
// gitzone config - manage CLI smartconfig configuration
|
||||
|
||||
import * as plugins from "./mod.plugins.js";
|
||||
import { ReleaseConfig } from "./classes.releaseconfig.js";
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
unsetCliConfigValueInData,
|
||||
writeSmartconfigFile,
|
||||
} from "../helpers.smartconfig.js";
|
||||
import {
|
||||
CURRENT_GITZONE_CLI_SCHEMA_VERSION,
|
||||
migrateSmartconfigData,
|
||||
} from "../helpers.smartconfigmigrations.js";
|
||||
|
||||
export { ReleaseConfig, CommitConfig };
|
||||
|
||||
@@ -99,6 +103,9 @@ export const run = async (argvArg: any) => {
|
||||
case "services":
|
||||
await handleServices(mode);
|
||||
break;
|
||||
case "migrate":
|
||||
await handleMigrate(value, mode);
|
||||
break;
|
||||
case "get":
|
||||
await handleGet(value, mode);
|
||||
break;
|
||||
@@ -138,10 +145,11 @@ async function handleInteractiveMenu(): Promise<void> {
|
||||
default: "show",
|
||||
choices: [
|
||||
{ name: "Show current configuration", value: "show" },
|
||||
{ name: "Add a registry", value: "add" },
|
||||
{ name: "Remove a registry", value: "remove" },
|
||||
{ name: "Clear all registries", value: "clear" },
|
||||
{ name: "Add an npm target registry", value: "add" },
|
||||
{ name: "Remove an npm target registry", value: "remove" },
|
||||
{ name: "Clear npm target registries", value: "clear" },
|
||||
{ name: "Set access level (public/private)", value: "access" },
|
||||
{ name: "Migrate smartconfig schema", value: "migrate" },
|
||||
{ name: "Configure commit options", value: "commit" },
|
||||
{ name: "Configure services", value: "services" },
|
||||
{ name: "Show help", value: "help" },
|
||||
@@ -166,6 +174,9 @@ async function handleInteractiveMenu(): Promise<void> {
|
||||
case "access":
|
||||
await handleAccessLevel(undefined, defaultCliMode);
|
||||
break;
|
||||
case "migrate":
|
||||
await handleMigrate(undefined, defaultCliMode);
|
||||
break;
|
||||
case "commit":
|
||||
await handleCommit(undefined, undefined, defaultCliMode);
|
||||
break;
|
||||
@@ -197,7 +208,7 @@ async function handleShow(mode: ICliMode): Promise<void> {
|
||||
"╭─────────────────────────────────────────────────────────────╮",
|
||||
);
|
||||
console.log(
|
||||
"│ Release Configuration │",
|
||||
"│ Release NPM Target Configuration │",
|
||||
);
|
||||
console.log(
|
||||
"╰─────────────────────────────────────────────────────────────╯",
|
||||
@@ -209,12 +220,12 @@ async function handleShow(mode: ICliMode): Promise<void> {
|
||||
console.log("");
|
||||
|
||||
if (registries.length === 0) {
|
||||
plugins.logger.log("info", "No release registries configured.");
|
||||
plugins.logger.log("info", "No npm target registries configured.");
|
||||
console.log("");
|
||||
console.log(" Run `gitzone config add <registry-url>` to add one.");
|
||||
console.log("");
|
||||
} else {
|
||||
plugins.logger.log("info", `Configured registries (${registries.length}):`);
|
||||
plugins.logger.log("info", `Configured npm target registries (${registries.length}):`);
|
||||
console.log("");
|
||||
registries.forEach((url, index) => {
|
||||
console.log(` ${index + 1}. ${url}`);
|
||||
@@ -224,7 +235,7 @@ async function handleShow(mode: ICliMode): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a registry URL
|
||||
* Add an npm target registry URL
|
||||
*/
|
||||
async function handleAdd(
|
||||
url: string | undefined,
|
||||
@@ -240,7 +251,7 @@ async function handleAdd(
|
||||
const response = await interactInstance.askQuestion({
|
||||
type: "input",
|
||||
name: "registryUrl",
|
||||
message: "Enter registry URL:",
|
||||
message: "Enter npm target registry URL:",
|
||||
default: "https://registry.npmjs.org",
|
||||
validate: (input: string) => {
|
||||
return !!(input && input.trim() !== "");
|
||||
@@ -263,7 +274,7 @@ async function handleAdd(
|
||||
});
|
||||
return;
|
||||
}
|
||||
plugins.logger.log("success", `Added registry: ${url}`);
|
||||
plugins.logger.log("success", `Added npm target registry: ${url}`);
|
||||
await formatSmartconfigWithDiff(mode);
|
||||
} else {
|
||||
plugins.logger.log("warn", `Registry already exists: ${url}`);
|
||||
@@ -271,7 +282,7 @@ async function handleAdd(
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a registry URL
|
||||
* Remove an npm target registry URL
|
||||
*/
|
||||
async function handleRemove(
|
||||
url: string | undefined,
|
||||
@@ -281,7 +292,7 @@ async function handleRemove(
|
||||
const registries = config.getRegistries();
|
||||
|
||||
if (registries.length === 0) {
|
||||
plugins.logger.log("warn", "No registries configured to remove.");
|
||||
plugins.logger.log("warn", "No npm target registries configured to remove.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -295,7 +306,7 @@ async function handleRemove(
|
||||
const response = await interactInstance.askQuestion({
|
||||
type: "list",
|
||||
name: "registryUrl",
|
||||
message: "Select registry to remove:",
|
||||
message: "Select npm target registry to remove:",
|
||||
choices: registries,
|
||||
default: registries[0],
|
||||
});
|
||||
@@ -315,7 +326,7 @@ async function handleRemove(
|
||||
});
|
||||
return;
|
||||
}
|
||||
plugins.logger.log("success", `Removed registry: ${url}`);
|
||||
plugins.logger.log("success", `Removed npm target registry: ${url}`);
|
||||
await formatSmartconfigWithDiff(mode);
|
||||
} else {
|
||||
plugins.logger.log("warn", `Registry not found: ${url}`);
|
||||
@@ -323,20 +334,20 @@ async function handleRemove(
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registries
|
||||
* Clear all npm target registries
|
||||
*/
|
||||
async function handleClear(mode: ICliMode): Promise<void> {
|
||||
const config = await ReleaseConfig.fromCwd();
|
||||
|
||||
if (!config.hasRegistries()) {
|
||||
plugins.logger.log("info", "No registries to clear.");
|
||||
plugins.logger.log("info", "No npm target registries to clear.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm before clearing
|
||||
const confirmed = mode.interactive
|
||||
? await plugins.smartinteract.SmartInteract.getCliConfirmation(
|
||||
"Clear all configured registries?",
|
||||
"Clear all configured npm target registries?",
|
||||
false,
|
||||
)
|
||||
: true;
|
||||
@@ -348,7 +359,7 @@ async function handleClear(mode: ICliMode): Promise<void> {
|
||||
printJson({ ok: true, action: "clear", registries: [] });
|
||||
return;
|
||||
}
|
||||
plugins.logger.log("success", "All registries cleared.");
|
||||
plugins.logger.log("success", "All npm target registries cleared.");
|
||||
await formatSmartconfigWithDiff(mode);
|
||||
} else {
|
||||
plugins.logger.log("info", "Operation cancelled.");
|
||||
@@ -473,6 +484,7 @@ async function handleCommitInteractive(config: CommitConfig): Promise<void> {
|
||||
const selected = (response as any).value || [];
|
||||
config.setAlwaysTest(selected.includes("alwaysTest"));
|
||||
config.setAlwaysBuild(selected.includes("alwaysBuild"));
|
||||
syncCommitStepsFromBooleans(config);
|
||||
await config.save();
|
||||
|
||||
plugins.logger.log("success", "Commit configuration updated");
|
||||
@@ -496,6 +508,7 @@ async function handleCommitSetting(
|
||||
} else if (setting === "alwaysBuild") {
|
||||
config.setAlwaysBuild(boolValue);
|
||||
}
|
||||
syncCommitStepsFromBooleans(config);
|
||||
|
||||
await config.save();
|
||||
if (mode.json) {
|
||||
@@ -506,6 +519,16 @@ async function handleCommitSetting(
|
||||
await formatSmartconfigWithDiff(mode);
|
||||
}
|
||||
|
||||
function syncCommitStepsFromBooleans(config: CommitConfig): void {
|
||||
config.setSteps([
|
||||
"analyze",
|
||||
...(config.getAlwaysTest() ? ["test"] : []),
|
||||
...(config.getAlwaysBuild() ? ["build"] : []),
|
||||
"changelog",
|
||||
"commit",
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show help for commit subcommand
|
||||
*/
|
||||
@@ -636,6 +659,38 @@ async function handleUnset(
|
||||
plugins.logger.log("success", `Unset ${configPath}`);
|
||||
}
|
||||
|
||||
async function handleMigrate(
|
||||
rawTargetVersion: string | undefined,
|
||||
mode: ICliMode,
|
||||
): Promise<void> {
|
||||
const targetVersion = rawTargetVersion
|
||||
? Number(rawTargetVersion)
|
||||
: CURRENT_GITZONE_CLI_SCHEMA_VERSION;
|
||||
if (!Number.isInteger(targetVersion) || targetVersion < 1) {
|
||||
throw new Error("Migration target version must be a positive integer");
|
||||
}
|
||||
|
||||
const smartconfigData = await readSmartconfigFile();
|
||||
const result = migrateSmartconfigData(smartconfigData, targetVersion);
|
||||
if (result.migrated) {
|
||||
await writeSmartconfigFile(smartconfigData);
|
||||
}
|
||||
|
||||
if (mode.json) {
|
||||
printJson({ ok: true, action: "migrate", ...result });
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.migrated) {
|
||||
plugins.logger.log(
|
||||
"success",
|
||||
`Migrated .smartconfig.json from schema v${result.fromVersion} to v${result.toVersion}`,
|
||||
);
|
||||
} else {
|
||||
plugins.logger.log("info", `.smartconfig.json already at schema v${result.toVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfigValue(rawValue: string): any {
|
||||
const trimmedValue = rawValue.trim();
|
||||
if (trimmedValue === "true") {
|
||||
@@ -676,21 +731,25 @@ export function showHelp(mode?: ICliMode): void {
|
||||
{ name: "get <path>", description: "Read a single config value" },
|
||||
{ name: "set <path> <value>", description: "Write a config value" },
|
||||
{ name: "unset <path>", description: "Delete a config value" },
|
||||
{ name: "add [url]", description: "Add a release registry" },
|
||||
{ name: "remove [url]", description: "Remove a release registry" },
|
||||
{ name: "clear", description: "Clear all release registries" },
|
||||
{ name: "add [url]", description: "Add an npm release target registry" },
|
||||
{ name: "remove [url]", description: "Remove an npm release target registry" },
|
||||
{ name: "clear", description: "Clear npm release target registries" },
|
||||
{
|
||||
name: "access [public|private]",
|
||||
description: "Set npm publish access level",
|
||||
description: "Set npm target publish access level",
|
||||
},
|
||||
{
|
||||
name: "commit <setting> <value>",
|
||||
description: "Set commit defaults",
|
||||
},
|
||||
{
|
||||
name: "migrate [version]",
|
||||
description: "Run version-targeted .smartconfig.json migrations",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"gitzone config show --json",
|
||||
"gitzone config get release.accessLevel",
|
||||
"gitzone config get release.targets.npm.accessLevel",
|
||||
"gitzone config set cli.interactive false",
|
||||
"gitzone config set cli.output json",
|
||||
],
|
||||
@@ -708,13 +767,14 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log(" get <path> Read a single config value");
|
||||
console.log(" set <path> <value> Write a config value");
|
||||
console.log(" unset <path> Delete a config value");
|
||||
console.log(" add [url] Add a registry URL");
|
||||
console.log(" remove [url] Remove a registry URL");
|
||||
console.log(" clear Clear all registries");
|
||||
console.log(" add [url] Add an npm target registry URL");
|
||||
console.log(" remove [url] Remove an npm target registry URL");
|
||||
console.log(" clear Clear npm target registries");
|
||||
console.log(
|
||||
" access [public|private] Set npm access level for publishing",
|
||||
" access [public|private] Set npm target access level for publishing",
|
||||
);
|
||||
console.log(" commit [setting] [value] Configure commit options");
|
||||
console.log(" migrate [version] Run version-targeted smartconfig migrations");
|
||||
console.log(
|
||||
" services Configure which services are enabled",
|
||||
);
|
||||
@@ -722,7 +782,7 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log("Examples:");
|
||||
console.log(" gitzone config show");
|
||||
console.log(" gitzone config show --json");
|
||||
console.log(" gitzone config get release.accessLevel");
|
||||
console.log(" gitzone config get release.targets.npm.accessLevel");
|
||||
console.log(" gitzone config set cli.interactive false");
|
||||
console.log(" gitzone config set cli.output json");
|
||||
console.log(" gitzone config unset cli.output");
|
||||
@@ -732,6 +792,7 @@ export function showHelp(mode?: ICliMode): void {
|
||||
console.log(" gitzone config clear");
|
||||
console.log(" gitzone config access public");
|
||||
console.log(" gitzone config access private");
|
||||
console.log(" gitzone config migrate 2");
|
||||
console.log(" gitzone config commit # Interactive");
|
||||
console.log(" gitzone config commit alwaysTest true");
|
||||
console.log(" gitzone config services # Interactive");
|
||||
|
||||
@@ -2,65 +2,7 @@ import { BaseFormatter } from "../classes.baseformatter.js";
|
||||
import type { IPlannedChange } from "../interfaces.format.js";
|
||||
import * as plugins from "../mod.plugins.js";
|
||||
import { logger, logVerbose } from "../../gitzone.logging.js";
|
||||
|
||||
/**
|
||||
* Migrates .smartconfig.json from old namespace keys to new package-scoped keys
|
||||
*/
|
||||
const migrateNamespaceKeys = (smartconfigJson: any): boolean => {
|
||||
let migrated = false;
|
||||
const migrations = [
|
||||
{ oldKey: "gitzone", newKey: "@git.zone/cli" },
|
||||
{ oldKey: "tsdoc", newKey: "@git.zone/tsdoc" },
|
||||
{ oldKey: "npmdocker", newKey: "@git.zone/tsdocker" },
|
||||
{ oldKey: "npmci", newKey: "@ship.zone/szci" },
|
||||
{ oldKey: "szci", newKey: "@ship.zone/szci" },
|
||||
];
|
||||
for (const { oldKey, newKey } of migrations) {
|
||||
if (smartconfigJson[oldKey]) {
|
||||
if (!smartconfigJson[newKey]) {
|
||||
smartconfigJson[newKey] = smartconfigJson[oldKey];
|
||||
} else {
|
||||
smartconfigJson[newKey] = {
|
||||
...smartconfigJson[oldKey],
|
||||
...smartconfigJson[newKey],
|
||||
};
|
||||
}
|
||||
delete smartconfigJson[oldKey];
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
return migrated;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migrates npmAccessLevel from @ship.zone/szci to @git.zone/cli.release.accessLevel
|
||||
*/
|
||||
const migrateAccessLevel = (smartconfigJson: any): boolean => {
|
||||
const szciConfig = smartconfigJson["@ship.zone/szci"];
|
||||
|
||||
if (!szciConfig?.npmAccessLevel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const gitzoneConfig = smartconfigJson["@git.zone/cli"] || {};
|
||||
if (gitzoneConfig?.release?.accessLevel) {
|
||||
delete szciConfig.npmAccessLevel;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!smartconfigJson["@git.zone/cli"]) {
|
||||
smartconfigJson["@git.zone/cli"] = {};
|
||||
}
|
||||
if (!smartconfigJson["@git.zone/cli"].release) {
|
||||
smartconfigJson["@git.zone/cli"].release = {};
|
||||
}
|
||||
|
||||
smartconfigJson["@git.zone/cli"].release.accessLevel =
|
||||
szciConfig.npmAccessLevel;
|
||||
delete szciConfig.npmAccessLevel;
|
||||
|
||||
return true;
|
||||
};
|
||||
import { migrateSmartconfigData } from "../../helpers.smartconfigmigrations.js";
|
||||
|
||||
const CONFIG_FILE = ".smartconfig.json";
|
||||
|
||||
@@ -88,9 +30,7 @@ export class SmartconfigFormatter extends BaseFormatter {
|
||||
|
||||
const smartconfigJson = JSON.parse(currentContent);
|
||||
|
||||
// Apply key migrations
|
||||
migrateNamespaceKeys(smartconfigJson);
|
||||
migrateAccessLevel(smartconfigJson);
|
||||
migrateSmartconfigData(smartconfigJson);
|
||||
|
||||
// Ensure namespaces exist
|
||||
if (!smartconfigJson["@git.zone/cli"]) {
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "../plugins.js";
|
||||
|
||||
import * as commitinfo from "@push.rocks/commitinfo";
|
||||
|
||||
export { commitinfo };
|
||||
@@ -17,8 +17,9 @@ const commandSummaries: ICommandHelpSummary[] = [
|
||||
{
|
||||
name: "commit",
|
||||
description:
|
||||
"Create semantic commits or generate read-only commit recommendations",
|
||||
"Analyze changes and create semantic source commits",
|
||||
},
|
||||
{ name: "release", description: "Create versioned releases from pending changelog entries" },
|
||||
{ name: "format", description: "Plan or apply project formatting changes" },
|
||||
{ name: "config", description: "Read and change .smartconfig.json settings" },
|
||||
{ name: "services", description: "Manage or configure development services" },
|
||||
@@ -68,7 +69,8 @@ export let run = async (argvArg: any = {}) => {
|
||||
message: "What would you like to do?",
|
||||
default: "commit",
|
||||
choices: [
|
||||
{ name: "Commit changes (semantic versioning)", value: "commit" },
|
||||
{ name: "Commit changes", value: "commit" },
|
||||
{ name: "Release pending changes", value: "release" },
|
||||
{ name: "Format project files", value: "format" },
|
||||
{ name: "Configure release settings", value: "config" },
|
||||
{ name: "Create from template", value: "template" },
|
||||
@@ -86,6 +88,11 @@ export let run = async (argvArg: any = {}) => {
|
||||
await modCommit.run({ _: ["commit"] });
|
||||
break;
|
||||
}
|
||||
case "release": {
|
||||
const modRelease = await import("../mod_release/index.js");
|
||||
await modRelease.run({ _: ["release"] });
|
||||
break;
|
||||
}
|
||||
case "format": {
|
||||
const modFormat = await import("../mod_format/index.js");
|
||||
await modFormat.run({ interactive: true });
|
||||
@@ -186,6 +193,7 @@ export async function showHelp(
|
||||
console.log(" gitzone help commit");
|
||||
console.log(" gitzone config show --json");
|
||||
console.log(" gitzone commit recommend --json");
|
||||
console.log(" gitzone release --plan");
|
||||
console.log(" gitzone format plan --json");
|
||||
console.log(" gitzone services set mongodb,minio");
|
||||
console.log("");
|
||||
@@ -203,6 +211,11 @@ async function showCommandHelp(
|
||||
modCommit.showHelp(mode);
|
||||
return true;
|
||||
}
|
||||
case "release": {
|
||||
const modRelease = await import("../mod_release/index.js");
|
||||
modRelease.showHelp(mode);
|
||||
return true;
|
||||
}
|
||||
case "config": {
|
||||
const modConfig = await import("../mod_config/index.js");
|
||||
modConfig.showHelp(mode);
|
||||
|
||||
Reference in New Issue
Block a user