feat(cli): split commit and release into target-based workflows
This commit is contained in:
@@ -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 || [],
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user