diff --git a/.smartconfig.json b/.smartconfig.json index dca1e7f..d788995 100644 --- a/.smartconfig.json +++ b/.smartconfig.json @@ -63,7 +63,8 @@ }, "docker": { "enabled": false, - "images": [] + "engine": "tsdocker", + "patterns": [] } } } diff --git a/changelog.md b/changelog.md index 3e2275d..fb2cb5a 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,11 @@ ## Pending +### Features + +- Delegate Docker release targets to `tsdocker`. + - Replace Docker image template publishing with `tsdocker push` execution. + - Move Docker registry behavior to `@git.zone/tsdocker` config and validate removed image config as invalid. ## 2026-05-10 - 2.18.1 diff --git a/readme.md b/readme.md index 4194a25..ad7daa7 100644 --- a/readme.md +++ b/readme.md @@ -147,7 +147,7 @@ Targets decide what happens after that: | --- | --- | | `git` | Pushes the release commit and tags, often triggering remote CI release builds | | `npm` | Publishes the package to configured npm registries | -| `docker` | Builds and pushes configured Docker images | +| `docker` | Delegates container builds and pushes to `tsdocker` | ```bash # Preview the resolved release plan @@ -205,7 +205,7 @@ The standard buckets are `Breaking Changes`, `Features`, `Fixes`, `Documentation ## Configuration -All CLI config lives under `@git.zone/cli` in `.smartconfig.json`. +CLI workflow config lives under `@git.zone/cli` in `.smartconfig.json`. Docker build and registry behavior lives under `@git.zone/tsdocker` and is used by the Docker release target. ```json { @@ -237,11 +237,21 @@ All CLI config lives under `@git.zone/cli` in `.smartconfig.json`. "alreadyPublished": "success" }, "docker": { - "enabled": false, - "images": [] + "enabled": true, + "engine": "tsdocker", + "patterns": [], + "cached": true, + "parallel": true } } } + }, + "@git.zone/tsdocker": { + "registries": ["registry.gitlab.com"], + "registryRepoMap": { + "registry.gitlab.com": "myorg/myproject" + }, + "platforms": ["linux/amd64", "linux/arm64"] } } ``` @@ -252,6 +262,12 @@ NPM registries belong only here: @git.zone/cli.release.targets.npm.registries ``` +Docker registries belong only here and should be registry hosts without `http://` or `https://`: + +```text +@git.zone/tsdocker.registries +``` + Useful config commands: ```bash diff --git a/ts/helpers.smartconfigmigrations.ts b/ts/helpers.smartconfigmigrations.ts index 51a9a78..3688388 100644 --- a/ts/helpers.smartconfigmigrations.ts +++ b/ts/helpers.smartconfigmigrations.ts @@ -68,8 +68,10 @@ const migrateToV2 = (smartconfigJson: Record): boolean => { migrated = true; } - if (isPlainObject(releaseConfig.docker) && !isPlainObject(targets.docker)) { - targets.docker = releaseConfig.docker; + if (isPlainObject(releaseConfig.docker)) { + targets.docker = isPlainObject(targets.docker) + ? { ...releaseConfig.docker, ...targets.docker } + : releaseConfig.docker; delete releaseConfig.docker; migrated = true; } @@ -141,11 +143,27 @@ const migrateToV2 = (smartconfigJson: Record): boolean => { if (dockerTarget.enabled === undefined) { dockerTarget.enabled = true; } + dockerTarget.engine = "tsdocker"; } delete releaseConfig.steps; migrated = true; } + if (isPlainObject(targets.docker)) { + if (targets.docker.images) { + delete targets.docker.images; + migrated = true; + } + if (targets.docker.engine !== "tsdocker") { + targets.docker.engine = "tsdocker"; + migrated = true; + } + if (!Array.isArray(targets.docker.patterns)) { + targets.docker.patterns = []; + migrated = true; + } + } + if (releaseConfig.changelog) { delete releaseConfig.changelog; migrated = true; diff --git a/ts/helpers.workflow.ts b/ts/helpers.workflow.ts index 04ac7d6..65158f0 100644 --- a/ts/helpers.workflow.ts +++ b/ts/helpers.workflow.ts @@ -52,7 +52,12 @@ export interface IReleaseNpmTargetConfig { export interface IReleaseDockerTargetConfig { enabled?: boolean; - images?: string[]; + engine?: "tsdocker"; + patterns?: string[]; + cached?: boolean; + parallel?: boolean | number; + context?: string; + noBuild?: boolean; } export interface IReleaseWorkflowConfig { @@ -109,7 +114,12 @@ export interface IResolvedReleaseWorkflow { npmAccessLevel: "public" | "private"; npmAlreadyPublished: "success" | "error"; dockerEnabled: boolean; - dockerImages: string[]; + dockerEngine: "tsdocker"; + dockerPatterns: string[]; + dockerCached: boolean; + dockerParallel: boolean | number; + dockerContext?: string; + dockerNoBuild: boolean; } interface ICliWorkflowConfig { @@ -382,6 +392,13 @@ export const resolveReleaseWorkflow = async (argvArg: any): Promise { 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 { } if (enabledTargets.includes("docker")) { - const images = await askValue(interactInstance, { + const patterns = await askValue(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(interactInstance, { + type: "confirm", + name: "dockerCached", + message: "Use tsdocker cached builds?", + default: currentTargets.docker?.cached ?? false, + }); + const parallel = await askValue(interactInstance, { + type: "input", + name: "dockerParallel", + message: "tsdocker parallel mode (false, true, or concurrency number):", + default: formatDockerParallel(currentTargets.docker?.parallel ?? false), + }); + const context = await askValue(interactInstance, { + type: "input", + name: "dockerContext", + message: "Docker context for tsdocker (empty for default):", + default: currentTargets.docker?.context || "", + }); + const noBuild = await askValue(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 { 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, + smartconfigData: Record, findings: IDoctorFinding[], ): Promise { 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, + smartconfigData: Record, findings: IDoctorFinding[], -): void { +): Promise { + 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 { + 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, + 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 { + 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, findings: IDoctorFinding[], diff --git a/ts/mod_release/index.ts b/ts/mod_release/index.ts index 2c6e449..1dbcdc5 100644 --- a/ts/mod_release/index.ts +++ b/ts/mod_release/index.ts @@ -107,7 +107,7 @@ export const run = async (argvArg: any) => { npmResults.push(...(await runNpmTarget(smartshellInstance, workflow))); } if (workflow.targets.includes("docker")) { - dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow, newVersion))); + dockerResults.push(...(await runDockerTarget(smartshellInstance, workflow))); } printReleaseSummary(newVersion, gitResults, npmResults, dockerResults); @@ -262,31 +262,43 @@ async function runNpmTarget( async function runDockerTarget( smartshellInstance: plugins.smartshell.Smartshell, workflow: IResolvedReleaseWorkflow, - newVersion: string, ): Promise { 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", - }); + const command = buildTsdockerPushCommand(workflow); + const result = await smartshellInstance.exec(command); + const output = `${result.stdout || ""}\n${(result as any).stderr || ""}\n${(result as any).combinedOutput || ""}`; + return [{ + target: workflow.dockerPatterns.length > 0 + ? `tsdocker:${workflow.dockerPatterns.join(",")}` + : "tsdocker", + status: result.exitCode === 0 ? "success" : "failed", + message: result.exitCode === 0 ? undefined : firstMeaningfulLine(output), + }]; +} + +function buildTsdockerPushCommand(workflow: IResolvedReleaseWorkflow): string { + const commandParts = ["tsdocker", "push"]; + if (workflow.dockerNoBuild) { + commandParts.push("--no-build"); } - return results; + if (workflow.dockerCached) { + commandParts.push("--cached"); + } + if (workflow.dockerParallel === true) { + commandParts.push("--parallel"); + } else if (typeof workflow.dockerParallel === "number" && Number.isFinite(workflow.dockerParallel) && workflow.dockerParallel > 0) { + commandParts.push(`--parallel=${Math.floor(workflow.dockerParallel)}`); + } + if (workflow.dockerContext) { + commandParts.push(`--context=${shellQuote(workflow.dockerContext)}`); + } + for (const pattern of workflow.dockerPatterns) { + commandParts.push(shellQuote(pattern)); + } + return commandParts.join(" "); } function isAlreadyPublishedOutput(output: string): boolean { @@ -315,11 +327,22 @@ function printReleasePlan(workflow: IResolvedReleaseWorkflow): void { 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(`docker engine: ${workflow.dockerEngine}`); + console.log(`docker patterns: ${workflow.dockerPatterns.length > 0 ? workflow.dockerPatterns.join(", ") : "all Dockerfiles"}`); + console.log(`docker options: ${formatDockerOptions(workflow)}`); } console.log(""); } +function formatDockerOptions(workflow: IResolvedReleaseWorkflow): string { + const options: string[] = []; + if (workflow.dockerCached) options.push("cached"); + if (workflow.dockerParallel) options.push(`parallel=${workflow.dockerParallel === true ? "true" : workflow.dockerParallel}`); + if (workflow.dockerNoBuild) options.push("no-build"); + if (workflow.dockerContext) options.push(`context=${workflow.dockerContext}`); + return options.length > 0 ? options.join(", ") : "default"; +} + function printReleaseSummary( newVersion: string, gitResults: ITargetResult[], @@ -365,7 +388,7 @@ export function showHelp(mode?: ICliMode): void { { flag: "-p, --push", description: "Enable the git release target" }, { flag: "--target ", 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: "--docker", description: "Enable the tsdocker release target" }, { flag: "--no-publish", description: "Run release core and git target only" }, { flag: "--plan", description: "Show resolved workflow without mutating files" }, ], @@ -385,7 +408,7 @@ export function showHelp(mode?: ICliMode): void { console.log(" -p, --push Enable the git release target"); console.log(" --target 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(" --docker Enable the tsdocker 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");