feat(release): delegate docker target to tsdocker

This commit is contained in:
2026-05-13 10:19:56 +00:00
parent c38e94bcf3
commit 26effadcc9
7 changed files with 331 additions and 53 deletions
+218 -20
View File
@@ -793,7 +793,7 @@ async function handleRelease(mode: ICliMode): Promise<void> {
choices: [
{ name: "git - push branch and tags", value: "git" },
{ name: "npm - publish package registries", value: "npm" },
{ name: "docker - build and push images", value: "docker" },
{ name: "docker - build and push through tsdocker", value: "docker" },
],
default: getDefaultEnabledTargets(currentTargets),
});
@@ -860,21 +860,49 @@ async function handleRelease(mode: ICliMode): Promise<void> {
}
if (enabledTargets.includes("docker")) {
const images = await askValue<string>(interactInstance, {
const patterns = await askValue<string>(interactInstance, {
type: "input",
name: "dockerImages",
message: "Docker image templates (comma-separated, supports {{version}}):",
default: Array.isArray(currentTargets.docker?.images)
? currentTargets.docker.images.join(", ")
name: "dockerPatterns",
message: "tsdocker Dockerfile patterns (comma-separated, empty means all):",
default: Array.isArray(currentTargets.docker?.patterns)
? currentTargets.docker.patterns.join(", ")
: "",
});
const cached = await askValue<boolean>(interactInstance, {
type: "confirm",
name: "dockerCached",
message: "Use tsdocker cached builds?",
default: currentTargets.docker?.cached ?? false,
});
const parallel = await askValue<string>(interactInstance, {
type: "input",
name: "dockerParallel",
message: "tsdocker parallel mode (false, true, or concurrency number):",
default: formatDockerParallel(currentTargets.docker?.parallel ?? false),
});
const context = await askValue<string>(interactInstance, {
type: "input",
name: "dockerContext",
message: "Docker context for tsdocker (empty for default):",
default: currentTargets.docker?.context || "",
});
const noBuild = await askValue<boolean>(interactInstance, {
type: "confirm",
name: "dockerNoBuild",
message: "Skip tsdocker build and only push existing local registry images?",
default: currentTargets.docker?.noBuild ?? false,
});
releaseTargets.docker = {
...(currentTargets.docker || {}),
enabled: true,
images: parseCsv(images),
engine: "tsdocker",
patterns: parseCsv(patterns),
cached,
parallel: parseDockerParallel(parallel),
context: context.trim() || undefined,
noBuild,
};
} else {
releaseTargets.docker = { ...(currentTargets.docker || {}), enabled: false };
releaseTargets.docker = { enabled: false, engine: "tsdocker" };
}
setCliConfigValueInData(smartconfigData, "schemaVersion", CURRENT_GITZONE_CLI_SCHEMA_VERSION);
@@ -1043,7 +1071,7 @@ async function collectDoctorFindings(): Promise<IDoctorFinding[]> {
await validateDetectedProjectType(cliConfig, findings);
validateCommitConfig(cliConfig.commit || {}, findings);
await validateReleaseConfig(cliConfig.release || {}, findings);
await validateReleaseConfig(cliConfig.release || {}, smartconfigData, findings);
return findings;
}
@@ -1291,9 +1319,14 @@ function formatTarget(enabled: unknown, targetConfig: any): string {
details.push(`registries=${targetConfig.registries.length}`);
}
if (targetConfig.accessLevel) details.push(`access=${targetConfig.accessLevel}`);
if (Array.isArray(targetConfig.images)) {
details.push(`images=${targetConfig.images.length}`);
if (targetConfig.engine) details.push(`engine=${targetConfig.engine}`);
if (Array.isArray(targetConfig.patterns)) {
details.push(`patterns=${targetConfig.patterns.length}`);
}
if (targetConfig.cached) details.push("cached=true");
if (targetConfig.parallel) details.push(`parallel=${targetConfig.parallel}`);
if (targetConfig.context) details.push(`context=${targetConfig.context}`);
if (targetConfig.noBuild) details.push("noBuild=true");
return details.length > 0 ? `${state} (${details.join(", ")})` : state;
}
@@ -1338,6 +1371,29 @@ function parseCsv(value: string): string[] {
return result;
}
function formatDockerParallel(value: unknown): string {
if (value === true) return "true";
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return String(Math.floor(value));
}
return "false";
}
function parseDockerParallel(value: string): boolean | number {
const normalizedValue = value.trim().toLowerCase();
if (!normalizedValue || ["false", "no", "off", "0"].includes(normalizedValue)) {
return false;
}
if (["true", "yes", "on"].includes(normalizedValue)) {
return true;
}
const numericValue = Number(normalizedValue);
if (Number.isFinite(numericValue) && numericValue > 0) {
return Math.floor(numericValue);
}
return false;
}
function normalizeRegistryUrl(url: string): string {
let normalizedUrl = url.trim();
if (!normalizedUrl.startsWith("http://") && !normalizedUrl.startsWith("https://")) {
@@ -1391,6 +1447,7 @@ function buildConfigFixPrompt(
`- Use schemaVersion ${CURRENT_GITZONE_CLI_SCHEMA_VERSION} for ` +
"`@git.zone/cli`.",
"- Use target-based release config: `release.targets.git`, `release.targets.npm`, and `release.targets.docker`.",
"- Docker release targets must use `release.targets.docker.engine = \"tsdocker\"`; Docker registries belong under `@git.zone/tsdocker`.",
"- Keep npm registries only at `@git.zone/cli.release.targets.npm.registries`.",
"- Do not add runtime legacy compatibility code. If legacy config exists, migrate it explicitly.",
"- Do not commit, release, install dependencies, or modify unrelated files.",
@@ -1514,6 +1571,7 @@ function validateCommitConfig(
async function validateReleaseConfig(
releaseConfig: Record<string, any>,
smartconfigData: Record<string, any>,
findings: IDoctorFinding[],
): Promise<void> {
const confirmation = releaseConfig.confirmation;
@@ -1554,7 +1612,7 @@ async function validateReleaseConfig(
const targets = releaseConfig.targets || {};
await validateGitTarget(targets.git || {}, findings);
await validateNpmTarget(targets.npm || {}, findings);
validateDockerTarget(targets.docker || {}, findings);
await validateDockerTarget(targets.docker || {}, smartconfigData, findings);
}
async function validateGitTarget(
@@ -1718,31 +1776,171 @@ async function validateNpmAuth(
}
}
function validateDockerTarget(
async function validateDockerTarget(
dockerTarget: Record<string, any>,
smartconfigData: Record<string, any>,
findings: IDoctorFinding[],
): void {
): Promise<void> {
if ("images" in dockerTarget) {
findings.push({
level: "error",
message: "Docker release target still uses removed images config",
fix: "Remove release.targets.docker.images and configure @git.zone/tsdocker instead.",
});
}
const enabled = dockerTarget.enabled ?? false;
if (!enabled) {
findings.push({ level: "ok", message: "Docker release target is disabled" });
return;
}
if (!Array.isArray(dockerTarget.images) || dockerTarget.images.length === 0) {
if (dockerTarget.engine !== "tsdocker") {
findings.push({
level: "error",
message: "Docker release target is enabled without images",
fix: "Set release.targets.docker.images or disable release.targets.docker.enabled.",
message: "Docker release target must use tsdocker",
fix: "Set release.targets.docker.engine to tsdocker.",
});
return;
}
if (dockerTarget.patterns !== undefined && !Array.isArray(dockerTarget.patterns)) {
findings.push({
level: "error",
message: "Docker release target patterns must be an array",
fix: "Set release.targets.docker.patterns to an array of Dockerfile patterns or remove it.",
});
}
if (!isValidDockerParallel(dockerTarget.parallel)) {
findings.push({
level: "error",
message: `Invalid tsdocker parallel setting: ${formatValue(dockerTarget.parallel)}`,
fix: "Use false, true, or a positive concurrency number.",
});
}
const tsdockerConfig = smartconfigData["@git.zone/tsdocker"];
if (!isPlainObject(tsdockerConfig)) {
findings.push({
level: "error",
message: "Docker release target is enabled but @git.zone/tsdocker config is missing",
fix: "Add @git.zone/tsdocker.registries and optional registryRepoMap/platforms config.",
});
} else {
validateTsdockerProjectConfig(tsdockerConfig, findings);
}
await validateTsdockerCommand(findings);
findings.push({
level: "ok",
message: `Docker release target uses tsdocker (${formatDockerPatterns(dockerTarget.patterns)})`,
});
}
function formatDockerPatterns(patterns: unknown): string {
return Array.isArray(patterns) && patterns.length > 0
? patterns.map((pattern) => String(pattern)).join(", ")
: "all Dockerfiles";
}
function isPlainObject(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isValidDockerParallel(value: unknown): boolean {
return value === undefined ||
value === false ||
value === true ||
(typeof value === "number" && Number.isFinite(value) && value > 0);
}
function validateTsdockerProjectConfig(
tsdockerConfig: Record<string, any>,
findings: IDoctorFinding[],
): void {
const registries = Array.isArray(tsdockerConfig.registries)
? tsdockerConfig.registries
: [];
if (registries.length === 0) {
findings.push({
level: "error",
message: "@git.zone/tsdocker.registries is empty",
fix: "Set @git.zone/tsdocker.registries to registry hosts such as registry.gitlab.com.",
});
}
for (const registry of registries) {
if (typeof registry !== "string" || !registry.trim()) {
findings.push({
level: "error",
message: `Invalid tsdocker registry: ${formatValue(registry)}`,
fix: "Use registry hosts such as registry.gitlab.com.",
});
continue;
}
if (registry.startsWith("http://") || registry.startsWith("https://")) {
findings.push({
level: "error",
message: `tsdocker registry must not include a protocol: ${registry}`,
fix: `Use ${registry.replace(/^https?:\/\//, "")}`,
});
}
}
const registryRepoMap = tsdockerConfig.registryRepoMap;
if (registryRepoMap !== undefined && !isPlainObject(registryRepoMap)) {
findings.push({
level: "error",
message: "@git.zone/tsdocker.registryRepoMap must be an object",
});
} else if (isPlainObject(registryRepoMap)) {
for (const registry of Object.keys(registryRepoMap)) {
if (registry.startsWith("http://") || registry.startsWith("https://")) {
findings.push({
level: "error",
message: `tsdocker registryRepoMap key must not include a protocol: ${registry}`,
fix: `Use ${registry.replace(/^https?:\/\//, "")}`,
});
}
}
}
findings.push({
level: "ok",
message: `Docker release target has ${dockerTarget.images.length} image template(s)`,
message: `@git.zone/tsdocker has ${registries.length} registries`,
});
}
async function validateTsdockerCommand(findings: IDoctorFinding[]): Promise<void> {
const smartshellInstance = new plugins.smartshell.Smartshell({
executor: "bash",
sourceFilePaths: [],
});
try {
const result = await smartshellInstance.execSpawn(
"tsdocker",
["--version"],
{ silent: true, timeout: 8000 },
);
if (result.exitCode === 0) {
findings.push({ level: "ok", message: "tsdocker command is available" });
} else {
findings.push({
level: "error",
message: "tsdocker command is not available",
fix: "Install @git.zone/tsdocker globally or make it available on PATH.",
});
}
} catch (error) {
findings.push({
level: "error",
message: "Could not execute tsdocker",
fix: error instanceof Error ? error.message : String(error),
});
}
}
async function validateDetectedProjectType(
cliConfig: Record<string, any>,
findings: IDoctorFinding[],