feat(cli/buildx): add pull control for builds and isolate buildx builders per project

This commit is contained in:
2026-03-19 10:18:10 +00:00
parent 3e4558abc5
commit 8cf8e43e59
7 changed files with 41 additions and 18 deletions

View File

@@ -1,5 +1,13 @@
# Changelog
## 2026-03-19 - 2.2.0 - feat(cli/buildx)
add pull control for builds and isolate buildx builders per project
- adds a new pull build option with --no-pull CLI support and defaults builds to refreshing base images with --pull
- passes the selected buildx builder explicitly into build commands instead of relying on global docker buildx use state
- generates project-hashed builder suffixes so concurrent runs from different project directories do not share the same local builder
- updates session logging to include project hash and builder suffix for easier build diagnostics
## 2026-03-15 - 2.1.0 - feat(cli)
add global remote builder configuration and native SSH buildx nodes for multi-platform builds

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@git.zone/tsdocker',
version: '2.1.0',
version: '2.2.0',
description: 'develop npm modules cross platform with docker'
}

View File

@@ -266,7 +266,7 @@ export class Dockerfile {
public static async buildDockerfiles(
sortedArrayArg: Dockerfile[],
session: TsDockerSession,
options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number; onRegistryStarted?: () => Promise<void>; onBeforeRegistryStop?: () => Promise<void> },
options?: { platform?: string; timeout?: number; noCache?: boolean; pull?: boolean; verbose?: boolean; isRootless?: boolean; parallel?: boolean; parallelConcurrency?: number; onRegistryStarted?: () => Promise<void>; onBeforeRegistryStop?: () => Promise<void> },
): Promise<Dockerfile[]> {
const total = sortedArrayArg.length;
const overallStart = Date.now();
@@ -668,13 +668,14 @@ export class Dockerfile {
/**
* Builds the Dockerfile
*/
public async build(options?: { platform?: string; timeout?: number; noCache?: boolean; verbose?: boolean }): Promise<number> {
public async build(options?: { platform?: string; timeout?: number; noCache?: boolean; pull?: boolean; verbose?: boolean }): Promise<number> {
const startTime = Date.now();
const buildArgsString = await Dockerfile.getDockerBuildArgs(this.managerRef);
const config = this.managerRef.config;
const platformOverride = options?.platform;
const timeout = options?.timeout;
const noCacheFlag = options?.noCache ? ' --no-cache' : '';
const pullFlag = options?.pull !== false ? ' --pull' : '';
const verbose = options?.verbose ?? false;
let buildContextFlag = '';
@@ -689,23 +690,24 @@ export class Dockerfile {
}
let buildCommand: string;
const builderFlag = this.managerRef.currentBuilderName ? ` --builder ${this.managerRef.currentBuilderName}` : '';
if (platformOverride) {
// Single platform override via buildx
buildCommand = `docker buildx build --progress=plain --platform ${platformOverride}${noCacheFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
buildCommand = `docker buildx build${builderFlag} --progress=plain --platform ${platformOverride}${noCacheFlag}${pullFlag}${buildContextFlag} --load -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
logger.log('info', `Build: buildx --platform ${platformOverride} --load`);
} else if (config.platforms && config.platforms.length > 1) {
// Multi-platform build using buildx — always push to local registry
const platformString = config.platforms.join(',');
const registryHost = this.session?.config.registryHost || 'localhost:5234';
const localTag = `${registryHost}/${this.buildTag}`;
buildCommand = `docker buildx build --progress=plain --platform ${platformString}${noCacheFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
buildCommand = `docker buildx build${builderFlag} --progress=plain --platform ${platformString}${noCacheFlag}${pullFlag}${buildContextFlag} -t ${localTag} -f ${this.filePath} ${buildArgsString} --push .`;
this.localRegistryTag = localTag;
logger.log('info', `Build: buildx --platform ${platformString} --push to local registry`);
} else {
// Standard build
const versionLabel = this.managerRef.projectInfo?.npm?.version || 'unknown';
buildCommand = `docker build --progress=plain --label="version=${versionLabel}"${noCacheFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
buildCommand = `docker build --progress=plain --label="version=${versionLabel}"${noCacheFlag}${pullFlag} -t ${this.buildTag} -f ${this.filePath} ${buildArgsString} .`;
logger.log('info', 'Build: docker build (standard)');
}

View File

@@ -25,6 +25,7 @@ export class TsDockerManager {
public projectInfo: any;
public dockerContext: DockerContext;
public session!: TsDockerSession;
public currentBuilderName?: string;
private dockerfiles: Dockerfile[] = [];
private activeRemoteBuilders: IRemoteBuilder[] = [];
private sshTunnelManager?: SshTunnelManager;
@@ -266,6 +267,7 @@ export class TsDockerManager {
platform: options?.platform,
timeout: options?.timeout,
noCache: options?.noCache,
pull: options?.pull,
verbose: options?.verbose,
});
logger.log('ok', `${progress} Built ${df.cleanTag} in ${formatDuration(elapsed)}`);
@@ -311,6 +313,7 @@ export class TsDockerManager {
platform: options?.platform,
timeout: options?.timeout,
noCache: options?.noCache,
pull: options?.pull,
verbose: options?.verbose,
});
logger.log('ok', `${progress} Built ${dockerfileArg.cleanTag} in ${formatDuration(elapsed)}`);
@@ -349,6 +352,7 @@ export class TsDockerManager {
platform: options?.platform,
timeout: options?.timeout,
noCache: options?.noCache,
pull: options?.pull,
verbose: options?.verbose,
isRootless: this.dockerContext.contextInfo?.isRootless,
parallel: options?.parallel,
@@ -401,6 +405,7 @@ export class TsDockerManager {
await this.ensureBuildxLocal(builderName);
}
this.currentBuilderName = builderName;
logger.log('ok', `Docker buildx ready (builder: ${builderName}, platforms: ${platforms})`);
}
@@ -426,7 +431,7 @@ export class TsDockerManager {
// Create the local node
const localPlatformFlag = localPlatforms.length > 0 ? ` --platform ${localPlatforms.join(',')}` : '';
await smartshellInstance.exec(
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host${localPlatformFlag} --use`
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host${localPlatformFlag}`
);
// Append remote nodes
@@ -441,7 +446,7 @@ export class TsDockerManager {
}
// Bootstrap all nodes
await smartshellInstance.exec('docker buildx inspect --bootstrap');
await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
// Store active remote builders for SSH tunnel setup during build
this.activeRemoteBuilders = remoteBuilders;
@@ -456,20 +461,18 @@ export class TsDockerManager {
if (inspectResult.exitCode !== 0) {
logger.log('info', 'Creating new buildx builder with host network...');
await smartshellInstance.exec(
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host --use`
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host`
);
await smartshellInstance.exec('docker buildx inspect --bootstrap');
await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
} else {
const inspectOutput = inspectResult.stdout || '';
if (!inspectOutput.includes('network=host')) {
logger.log('info', 'Recreating buildx builder with host network (migration)...');
await smartshellInstance.exec(`docker buildx rm ${builderName} 2>/dev/null`);
await smartshellInstance.exec(
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host --use`
`docker buildx create --name ${builderName} --driver docker-container --driver-opt network=host`
);
await smartshellInstance.exec('docker buildx inspect --bootstrap');
} else {
await smartshellInstance.exec(`docker buildx use ${builderName}`);
await smartshellInstance.exec(`docker buildx inspect --builder ${builderName} --bootstrap`);
}
}
this.activeRemoteBuilders = [];

View File

@@ -4,6 +4,7 @@ import { logger } from './tsdocker.logging.js';
export interface ISessionConfig {
sessionId: string;
projectHash: string;
registryPort: number;
registryHost: string;
registryContainerName: string;
@@ -17,8 +18,8 @@ export interface ISessionConfig {
* Generates unique ports, container names, and builder names so that
* concurrent CI jobs on the same Docker host don't collide.
*
* In local (non-CI) dev the builder suffix is empty, preserving the
* persistent builder behavior.
* In local (non-CI) dev the builder suffix contains a project hash so
* that concurrent runs in different project directories use separate builders.
*/
export class TsDockerSession {
public config: ISessionConfig;
@@ -34,16 +35,18 @@ export class TsDockerSession {
public static async create(): Promise<TsDockerSession> {
const sessionId =
process.env.TSDOCKER_SESSION_ID || crypto.randomBytes(4).toString('hex');
const projectHash = crypto.createHash('sha256').update(process.cwd()).digest('hex').substring(0, 8);
const registryPort = await TsDockerSession.allocatePort();
const registryHost = `localhost:${registryPort}`;
const registryContainerName = `tsdocker-registry-${sessionId}`;
const { isCI, ciSystem } = TsDockerSession.detectCI();
const builderSuffix = isCI ? `-${sessionId}` : '';
const builderSuffix = isCI ? `-${projectHash}-${sessionId}` : `-${projectHash}`;
const config: ISessionConfig = {
sessionId,
projectHash,
registryPort,
registryHost,
registryContainerName,
@@ -99,9 +102,10 @@ export class TsDockerSession {
logger.log('info', '=== TSDOCKER SESSION ===');
logger.log('info', `Session ID: ${c.sessionId}`);
logger.log('info', `Registry: ${c.registryHost} (container: ${c.registryContainerName})`);
logger.log('info', `Project hash: ${c.projectHash}`);
logger.log('info', `Builder suffix: ${c.builderSuffix}`);
if (c.isCI) {
logger.log('info', `CI detected: ${c.ciSystem}`);
logger.log('info', `Builder suffix: ${c.builderSuffix}`);
}
}
}

View File

@@ -69,6 +69,7 @@ export interface IBuildCommandOptions {
platform?: string; // Single platform override (e.g., 'linux/arm64')
timeout?: number; // Build timeout in seconds
noCache?: boolean; // Force rebuild without Docker layer cache (--no-cache)
pull?: boolean; // Pull fresh base images before building (default: true)
cached?: boolean; // Skip builds when Dockerfile content hasn't changed
verbose?: boolean; // Stream raw docker build output (default: silent)
context?: string; // Explicit Docker context name (--context flag)

View File

@@ -41,6 +41,7 @@ BUILD / PUSH OPTIONS
--platform=<p> Target platform (e.g. linux/arm64)
--timeout=<s> Build timeout in seconds
--no-cache Rebuild without Docker layer cache
--no-pull Skip pulling latest base images (use cached)
--cached Skip builds when Dockerfile is unchanged
--verbose Stream raw docker build output
--parallel[=<n>] Parallel builds (optional concurrency limit)
@@ -120,6 +121,8 @@ export let run = () => {
if (argvArg.cache === false) {
buildOptions.noCache = true;
}
// --pull is default true; --no-pull sets pull=false
buildOptions.pull = argvArg.pull !== false;
if (argvArg.cached) {
buildOptions.cached = true;
}
@@ -170,6 +173,7 @@ export let run = () => {
if (argvArg.cache === false) {
buildOptions.noCache = true;
}
buildOptions.pull = argvArg.pull !== false;
if (argvArg.verbose) {
buildOptions.verbose = true;
}
@@ -243,6 +247,7 @@ export let run = () => {
if (argvArg.cache === false) {
buildOptions.noCache = true;
}
buildOptions.pull = argvArg.pull !== false;
if (argvArg.cached) {
buildOptions.cached = true;
}