Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f12250b
Build/Test Tools: Propagate the exit status of the local environment …
adimoldovan Jul 28, 2026
0e97f64
Merge branch 'trunk' into fix-local-env-discarded-exit-status
adimoldovan Jul 28, 2026
a88479c
Build/Test Tools: Only treat SIGINT as a clean exit of a local enviro…
adimoldovan Jul 28, 2026
4d276a5
Merge branch 'trunk' into fix-local-env-discarded-exit-status
lancewillett Jul 29, 2026
d93c948
Merge branch 'trunk' into fix-local-env-discarded-exit-status
adimoldovan Jul 30, 2026
b966352
Merge branch 'trunk' into fix-local-env-discarded-exit-status
adimoldovan Aug 4, 2026
cffa4bb
Merge branch 'trunk' into fix-local-env-discarded-exit-status
adimoldovan Aug 11, 2026
1dfe3a7
Merge branch 'trunk' into fix-local-env-discarded-exit-status
adimoldovan Aug 12, 2026
5fd43cf
Build/Test Tools: Retry the local environment commands that reach a r…
adimoldovan Aug 13, 2026
75be36c
Build/Test Tools: Narrow which local environment commands are re-atte…
adimoldovan Aug 13, 2026
3d99050
Build/Test Tools: Match the Composer subcommand anywhere in the command.
adimoldovan Aug 14, 2026
e6f1902
Build/Test Tools: Identify the Composer command before re-attempting it.
adimoldovan Aug 14, 2026
dc08ef9
Merge branch 'trunk' into fix-local-env-discarded-exit-status
adimoldovan Aug 18, 2026
bd85969
Merge branch 'trunk' into fix-local-env-discarded-exit-status
adimoldovan Aug 27, 2026
2373931
Merge branch 'trunk' into fix-local-env-discarded-exit-status
adimoldovan Aug 28, 2026
87734e3
Merge branch 'trunk' into fix-local-env-discarded-exit-status
lancewillett Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 30 additions & 31 deletions tools/local-env/scripts/docker.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

const dotenv = require( 'dotenv' );
const dotenvExpand = require( 'dotenv-expand' );
const { spawnSync } = require( 'child_process' );
const local_env_utils = require( './utils' );

dotenvExpand.expand( dotenv.config() );
local_env_utils.ensure_env_file();

const composeFiles = local_env_utils.get_compose_files();
dotenvExpand.expand( dotenv.config() );

if ( process.argv.includes( '--coverage-html' ) ) {
process.env.LOCAL_PHP_XDEBUG = 'true';
Expand All @@ -25,39 +24,39 @@ if ( dockerCommand.includes( 'cli' ) && dockerCommand.includes( 'db' ) && ! dock
dockerCommand.push( '--defaults' );
}

const composeArgs = [
'compose',
...composeFiles
.map( ( composeFile ) => [ '-f', composeFile ] )
.flat(),
...dockerCommand,
];

// Failures during image pulls are re-attempted to rule out registry rate limits and network issues.
const maxAttempts = 'pull' === dockerCommand[0] ? 3 : 1;

// Execute any Docker compose command passed to this script.
let returns;
for ( let attempt = 1; attempt <= maxAttempts; attempt++ ) {
returns = spawnSync( 'docker', composeArgs, { stdio: 'inherit' } );

if ( 0 === returns.status ) {
// `composer install` and `composer update` reach repo.packagist.org for the same reason, and both
// are safe to repeat. Every other Composer command runs once, so a failure such as a PHPStan error
// is reported as the real result it is.
const composerArgs = dockerCommand.slice( dockerCommand.indexOf( 'composer' ) + 1 );
let composerCommand;

for ( let i = 0; i < composerArgs.length; i++ ) {
// Global options precede the command. `--working-dir` is the only one that takes a separate
// value, so it is the only value that could otherwise be mistaken for the command itself.
if ( '-d' === composerArgs[i] || '--working-dir' === composerArgs[i] ) {
i++;
} else if ( ! composerArgs[i].startsWith( '-' ) ) {
composerCommand = composerArgs[i];
break;
}
}

if ( attempt === maxAttempts ) {
if ( maxAttempts > 1 ) {
console.log( `\ndocker compose ${ dockerCommand[0] } failed after ${ attempt } attempts.` );
}
const retryable = 'pull' === dockerCommand[0] ||
( 'run' === dockerCommand[0] && dockerCommand.includes( 'composer' ) &&
[ 'install', 'update' ].includes( composerCommand ) );

break;
}

const delay = attempt * 10;
console.log( `\ndocker compose ${ dockerCommand[0] } failed (attempt ${ attempt } of ${ maxAttempts }). Retrying in ${ delay } seconds...\n` );
// Execute any Docker compose command passed to this script.
const returns = local_env_utils.compose_with_retry( dockerCommand, retryable ? 3 : 1 );

// Sleep synchronously so the retry loop stays in order without going async.
Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, delay * 1000 );
if ( returns.error ) {
console.error( `Could not run Docker Compose. ${ returns.error.message }` );
} else if ( returns.signal && returns.signal !== 'SIGINT' ) {
console.error( `Docker Compose was terminated by ${ returns.signal }.` );
}

process.exit( returns.status );
// `status` is null when Docker could not be spawned at all, or was killed by a signal. Ctrl-C
// signals the whole process group, so this script usually dies alongside Compose without reaching
// here. This covers a signal sent to Compose alone: SIGINT means the command was cancelled, as
// when ending `env:logs`, and every other signal means it was killed before it finished.
process.exit( returns.signal === 'SIGINT' ? 0 : ( returns.status ?? 1 ) );
2 changes: 2 additions & 0 deletions tools/local-env/scripts/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const { execSync } = require( 'child_process' );
const { readFileSync, writeFileSync } = require( 'fs' );
const local_env_utils = require( './utils' );

local_env_utils.ensure_env_file();

dotenvExpand.expand( dotenv.config() );

// Create wp-config.php. This verifies the database connection, so retrying it doubles as the
Expand Down
33 changes: 14 additions & 19 deletions tools/local-env/scripts/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,11 @@ const dotenv = require( 'dotenv' );
const dotenvExpand = require( 'dotenv-expand' );
const { execSync, spawnSync } = require( 'child_process' );
const local_env_utils = require( './utils' );
const { copyFileSync, existsSync } = require( 'node:fs' );

// Copy the default .env file when one is not present.
if ( ! existsSync( '.env' ) ) {
copyFileSync( '.env.example', '.env' );
}
local_env_utils.ensure_env_file();

dotenvExpand.expand( dotenv.config() );

const composeFiles = local_env_utils.get_compose_files();

// Check if the Docker service is running.
try {
execSync( 'docker info' );
Expand All @@ -32,18 +26,19 @@ if ( process.env.LOCAL_PHP_MEMCACHED === 'true' ) {
containers.push( 'memcached' );
}

spawnSync(
'docker',
[
'compose',
...composeFiles.map( ( composeFile ) => [ '-f', composeFile ] ).flat(),
'up',
'--quiet-pull',
'-d',
...containers,
],
{ stdio: 'inherit' }
);
// `up` pulls any image that is missing, so it is re-attempted for the same reasons as `env:pull`.
const up = local_env_utils.compose_with_retry( [ 'up', '--quiet-pull', '-d', ...containers ], 3 );

// No signal is exempt here, unlike in `docker.js`: `env:start` runs `composer update -W` next, and
// that must not run against containers that never came up.
if ( up.status !== 0 ) {
const reason = up.signal ? `It was terminated by ${ up.signal }.` : up.error?.message ?? '';

console.error( `Could not start the Docker containers. ${ reason }`.trim() );

// `status` is null when Docker could not be spawned at all, or was killed by a signal.
process.exit( up.status ?? 1 );
}

// If Docker Toolbox is being used, we need to manually forward LOCAL_PORT to the Docker VM.
if ( process.env.DOCKER_TOOLBOX_INSTALL_PATH ) {
Expand Down
78 changes: 77 additions & 1 deletion tools/local-env/scripts/utils.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,85 @@
/* jshint node:true */

const { existsSync } = require( 'node:fs' );
const { spawnSync } = require( 'node:child_process' );
const { constants, copyFileSync, existsSync } = require( 'node:fs' );
const { join } = require( 'node:path' );

const repo_root = join( __dirname, '..', '..', '..' );

const local_env_utils = {

/**
* Creates the .env file from .env.example when one is not present.
*
* Docker Compose reads this file to resolve the image tags, so it must exist before any
* Compose command runs, not just before the containers are started.
*/
ensure_env_file: function() {
try {
copyFileSync( join( repo_root, '.env.example' ), join( repo_root, '.env' ), constants.COPYFILE_EXCL );
} catch ( e ) {
// A .env that is already there is the common case and needs no warning. Any other
// failure means the scripts run without the settings from .env, which is worth
// reporting, but is never a reason to refuse to run a command such as `env:stop`.
if ( e.code !== 'EEXIST' ) {
console.warn( `Could not create a .env file from .env.example. ${ e.message }` );
}
}
},

/**
* Runs a Docker Compose command, re-attempting it when it fails.
*
* Any command that reaches a registry can fail for reasons that clear on their own, such as
* rate limits and transient network errors.
*
* @param {string[]} args The Compose command and its arguments, such as `[ 'pull' ]`.
* @param {number} attempts How many times to run the command before giving up.
*
* @return {Object} The result of the last attempt.
*/
compose_with_retry: function( args, attempts ) {
const composeArgs = [
'compose',
...local_env_utils.get_compose_files()
.map( ( composeFile ) => [ '-f', composeFile ] )
.flat(),
...args,
];

let returns;

for ( let attempt = 1; attempt <= attempts; attempt++ ) {
returns = spawnSync( 'docker', composeArgs, { stdio: 'inherit' } );

if ( 0 === returns.status ) {
break;
}

// A command killed by a signal was cancelled rather than failed, and a command that
// could not be spawned at all fails the same way every time. Do not run either again.
if ( returns.signal || returns.error ) {
break;
}

if ( attempt === attempts ) {
if ( attempts > 1 ) {
console.log( `\ndocker compose ${ args[0] } failed after ${ attempt } attempts.` );
}

break;
}

const delay = attempt * 10;
console.log( `\ndocker compose ${ args[0] } failed (attempt ${ attempt } of ${ attempts }). Retrying in ${ delay } seconds...\n` );

// Sleep synchronously so the retry loop stays in order without going async.
Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, delay * 1000 );
}

return returns;
},

/**
* Determines which Docker compose files are required to properly configure the local environment given the
* specified PHP version, database type, and database version.
Expand Down
Loading