Introduce retry mechanism for npm install in CI/CD

This commit addresses occasional pipeline failures caused by transient
network errors during dependency installation with `npm ci`. It
centralizes the logic for installing npm dependencies and introduces a
retry mechanism.

The new approach will attempt `npm ci` up to 5 times with a 5-second
interval between each attempt, thereby increasing the resilience of
CI/CD pipelines.

This commit adds a new script `npm-install.js` with `npm run
install-deps` command to centralize npm dependency installation process
throughout the project. Separate testing of scripts to a separate
workflow.

It removes unused `install` dependency from `package.json`.
This commit is contained in:
undergroundwires
2023-09-05 13:39:15 +02:00
parent 0a2a1a026b
commit 4beb1bb574
18 changed files with 313 additions and 190 deletions

View File

@@ -1,95 +0,0 @@
#!/usr/bin/env bash
# Description:
# This script ensures npm is available, removes existing node modules, optionally
# removes package-lock.json (when -n flag is used), installs dependencies and runs unit tests.
# Usage:
# ./fresh-npm-install.sh # Regular execution
# ./fresh-npm-install.sh -n # Non-deterministic mode (removes package-lock.json)
declare NON_DETERMINISTIC_FLAG=0
main() {
parse_args "$@"
ensure_npm_is_available
ensure_npm_root
remove_existing_modules
if [[ $NON_DETERMINISTIC_FLAG -eq 1 ]]; then
remove_package_lock_json
fi
install_dependencies
run_unit_tests
}
ensure_npm_is_available() {
if ! command -v npm &> /dev/null; then
log::fatal 'npm could not be found, please install it first.'
fi
}
ensure_npm_root() {
if [ ! -f package.json ]; then
log::fatal 'Current directory is not a npm root. Please run the script in a npm root directory.'
fi
}
remove_existing_modules() {
if [ -d ./node_modules ]; then
log::info 'Removing existing node modules...'
if ! rm -rf ./node_modules; then
log::fatal 'Could not remove existing node modules.'
fi
fi
}
install_dependencies() {
log::info 'Installing dependencies...'
if ! npm install; then
log::fatal 'Failed to install dependencies.'
fi
}
remove_package_lock_json() {
if [ -f ./package-lock.json ]; then
log::info 'Removing package-lock.json...'
if ! rm -rf ./package-lock.json; then
log::fatal 'Could not remove package-lock.json.'
fi
fi
}
run_unit_tests() {
log::info 'Running unit tests...'
if ! npm run test:unit; then
pwd
log::fatal 'Failed to run unit tests.'
fi
}
log::info() {
local -r message="$1"
echo "📣 ${message}"
}
log::fatal() {
local -r message="$1"
echo "${message}" >&2
exit 1
}
parse_args() {
while getopts "n" opt; do
case ${opt} in
n)
NON_DETERMINISTIC_FLAG=1
;;
\?)
echo "Invalid option: $OPTARG" 1>&2
exit 1
;;
esac
done
}
main "$1"

199
scripts/npm-install.js Normal file
View File

@@ -0,0 +1,199 @@
/*
Description:
This script manages NPM dependencies for a project.
It offers capabilities like doing a fresh install, retries on network errors, and other features.
Usage:
npm run install-deps [-- <options>]
node scripts/npm-install.js [options]
Options:
--root-directory <path>
Specifies the root directory where package.json resides
Defaults to the current working directory.
Example: npm run install-deps -- --root-directory /your/path/here
--no-errors
Ignores errors and continues the execution.
Example: npm run install-deps -- --no-errors
--ci
Uses 'npm ci' for dependency installation instead of 'npm install'.
Example: npm run install-deps -- --ci
--fresh
Removes the existing node_modules directory before installing dependencies.
Example: npm run install-deps -- --fresh
--non-deterministic
Removes package-lock.json for a non-deterministic installation.
Example: npm run install-deps -- --non-deterministic
Note:
Flags can be combined as needed.
Example: npm run install-deps -- --fresh --non-deterministic
*/
import { exec } from 'child_process';
import { resolve } from 'path';
import { access, rm, unlink } from 'fs/promises';
import { constants } from 'fs';
const MAX_RETRIES = 5;
const RETRY_DELAY_IN_MS = 5 /* seconds */ * 1000;
const ARG_NAMES = {
rootDirectory: '--root-directory',
ignoreErrors: '--no-errors',
ci: '--ci',
fresh: '--fresh',
nonDeterministic: '--non-deterministic',
};
async function main() {
const options = getOptions();
console.log('Options:', options);
await ensureNpmRootDirectory(options.rootDirectory);
await ensureNpmIsAvailable();
if (options.fresh) {
await removeNodeModules(options.rootDirectory);
}
if (options.nonDeterministic) {
await removePackageLockJson(options.rootDirectory);
}
const command = buildCommand(options.ci, options.outputErrors);
console.log('Starting dependency installation...');
const exitCode = await executeWithRetry(
command,
options.workingDirectory,
MAX_RETRIES,
RETRY_DELAY_IN_MS,
);
if (exitCode === 0) {
console.log('🎊 Installed dependencies...');
} else {
console.error(`💀 Failed to install dependencies, exit code: ${exitCode}`);
}
process.exit(exitCode);
}
async function removeNodeModules(workingDirectory) {
const nodeModulesDirectory = resolve(workingDirectory, 'node_modules');
if (await exists('./node_modules')) {
console.log('Removing node_modules...');
await rm(nodeModulesDirectory, { recursive: true });
}
}
async function removePackageLockJson(workingDirectory) {
const packageLockJsonFile = resolve(workingDirectory, 'package-lock.json');
if (await exists(packageLockJsonFile)) {
console.log('Removing package-lock.json...');
await unlink(packageLockJsonFile);
}
}
async function ensureNpmIsAvailable() {
const exitCode = await executeCommand('npm --version');
if (exitCode !== 0) {
throw new Error('`npm` in not available!');
}
}
async function ensureNpmRootDirectory(workingDirectory) {
const packageJsonPath = resolve(workingDirectory, 'package.json');
if (!await exists(packageJsonPath)) {
throw new Error(`Not an NPM project root: ${workingDirectory}`);
}
}
function buildCommand(ci, outputErrors) {
const baseCommand = ci ? 'npm ci' : 'npm install';
if (!outputErrors) {
return `${baseCommand} --loglevel=error`;
}
return baseCommand;
}
function getOptions() {
const processArgs = process.argv.slice(2); // Slice off the node and script name
return {
rootDirectory: processArgs.includes('--root-directory') ? processArgs[processArgs.indexOf('--root-directory') + 1] : process.cwd(),
outputErrors: !processArgs.includes(ARG_NAMES.ignoreErrors),
ci: processArgs.includes(ARG_NAMES.ci),
fresh: processArgs.includes(ARG_NAMES.fresh),
nonDeterministic: processArgs.includes(ARG_NAMES.nonDeterministic),
};
}
async function executeWithRetry(
command,
workingDirectory,
maxRetries,
retryDelayInMs,
currentAttempt = 1,
) {
const statusCode = await executeCommand(command, workingDirectory, true, true);
if (statusCode === 0 || currentAttempt >= maxRetries) {
return statusCode;
}
console.log(`⚠️🔄 Attempt ${currentAttempt} failed. Retrying in ${retryDelayInMs / 1000} seconds...`);
await sleep(retryDelayInMs);
const retryResult = await executeWithRetry(
command,
workingDirectory,
maxRetries,
retryDelayInMs,
currentAttempt + 1,
);
return retryResult;
}
async function executeCommand(
command,
workingDirectory = process.cwd(),
logStdout = false,
logCommand = false,
) {
if (logCommand) {
console.log(`▶️ Executing command "${command}" at "${workingDirectory}"`);
}
const process = exec(
command,
{
cwd: workingDirectory,
},
);
if (logStdout) {
process.stdout.on('data', (data) => {
console.log(data.toString());
});
}
process.stderr.on('data', (data) => {
console.error(data.toString());
});
return new Promise((resolve) => {
process.on('exit', (code) => {
resolve(code);
});
});
}
function sleep(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function exists(path) {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
await main();