packages/eui/scripts/release.js (273 lines of code) (raw):

const yargs = require('yargs/yargs'); const { hideBin } = require('yargs/helpers'); const chalk = require('chalk'); const path = require('path'); let { execSync } = require('child_process'); const cwd = path.resolve(__dirname, '..'); const stdio = 'inherit'; const execOptions = { cwd, stdio }; const updateTokenChangelog = require('./update-token-changelog'); const { collateChangelogFiles, updateChangelog, } = require('./update-changelog'); const { getUpcomingVersion, updateDocsVersionSwitcher, } = require('./update-versions-log'); const TYPE_MAJOR = 'major'; const TYPE_MINOR = 'minor'; const TYPE_PATCH = 'patch'; const TYPE_BACKPORT = 'backport'; const TYPE_PRERELEASE = 'prerelease'; // NOTE: Because this script has to be run with `npm`, args must be passed after an extra `--` // e.g. `npm run release -- --dry-run`, `npm run release -- --steps=build,version` const args = yargs(hideBin(process.argv)) .parserConfiguration({ 'camel-case-expansion': false, 'halt-at-non-option': true, }) .describe('Tag and publish a new version of EUI') .options({ 'dry-run': { type: 'boolean', default: false, describe: 'Dry run mode; no changes are made', }, type: { type: 'string', choices: [ TYPE_MAJOR, TYPE_MINOR, TYPE_PATCH, TYPE_BACKPORT, TYPE_PRERELEASE, ], describe: 'Version type; For normal releases, can be "major", "minor" or "patch". Special releases: "backport" and "prerelease". If not passed, will be automatically prompted for based on the upcoming changelogs.', }, steps: { type: 'string', describe: 'Which release steps to run; a comma-separated list of values that can include "test", "build", "version", "tag", and "publish". If no value is given, all steps are run. Example: --steps=test,build,version,tag', coerce: (value) => { if (value) { const allSteps = ['test', 'build', 'version', 'tag', 'publish']; const steps = value.split(',').map((step) => step.trim()); const invalidSteps = steps.filter((step) => !allSteps.includes(step)); if (invalidSteps.length > 0) { console.error(`Invalid --step(s): ${invalidSteps.join(', ')}`); process.exit(1); } } return value; }, }, }).argv; const isSpecialRelease = args.type === TYPE_BACKPORT || args.type === TYPE_PRERELEASE; const isDryRun = args['dry-run'] === true; const hasStep = (step) => { if (!args.steps) return true; // If no steps were passed, run them all return args.steps.includes(step); }; /** * Main script */ (async function () { // make sure the release script is being run by npm (required for `npm publish` step) // https://github.com/yarnpkg/yarn/issues/5063 const packageManagerScript = path.basename(process.env.npm_execpath || ''); if (packageManagerScript !== 'npm-cli.js') { console.error('The release script must be run with npm: npm run release'); process.exit(1); } if (isDryRun) { console.warn( chalk.yellow('Dry run mode: no changes will be pushed to npm or Github') ); } else { // ensure git and local setup is at latest await ensureCorrectSetup(); } // run lint, unit, and e2e tests if (hasStep('test')) { execSync('npm run test-ci', execOptions); } // (trans|com)pile `src` into `lib` and `dist` if (hasStep('build')) { execSync('npm run build', execOptions); } let versionTarget; if (hasStep('version')) { // Fetch latest tags and clear any local ones execSync('git fetch upstream --tags --prune --prune-tags --force'); // Prompt user for what type of version bump to make (major|minor|patch) based on the upcoming changelogs const { changelogMap, changelog } = collateChangelogFiles(); const versionType = await getVersionTypeFromChangelog(changelogMap); // Get the upcoming version target versionTarget = getUpcomingVersion(versionType); // build may have generated a new i18ntokens.json file, dirtying the git workspace // it's important to track those changes with this release, so determine the changes and write them // to i18ntokens_changelog.json, committing both to the workspace before running `npm version` await updateTokenChangelog(versionTarget); // Update version switcher data and changelog if (!isSpecialRelease) { updateDocsVersionSwitcher(versionTarget); } updateChangelog(changelog, versionTarget); execSync('git commit -m "Updated changelog" -n'); // Update version number execSync(`yarn version ${versionTarget}`, execOptions); // `yarn version` sometimes has a bug with the suffixed releases (e.g. `-backport.*`) // where it doesn't properly set the version target. Running the command twice in a row // appears to fix the issue for some reason ¯\_(ツ)_/¯ if (isSpecialRelease) { execSync(`yarn version ${versionTarget}`, execOptions); } // Commit version number update execSync('git add package.json', execOptions); execSync(`git commit --no-verify -m "${versionTarget}"`, execOptions); } if (hasStep('tag') && !isDryRun) { // Create a tag execSync(`git tag -a -m "v${versionTarget}" "v${versionTarget}"`, execOptions); // Skip prepush test hook on all pushes - we should have already tested previously, // or we skipped the test step for a reason if (isSpecialRelease) { // Only push the tag, not the branch execSync(`git push upstream v${versionTarget} --no-verify`, execOptions); } else { // Push commits as well as tag execSync(`git push upstream --follow-tags --no-verify`, execOptions); } } if (hasStep('publish') && !isDryRun) { // prompt user for npm 2FA const otp = await getOneTimePassword(versionTarget); // publish new version to npm if (isSpecialRelease) { execSync(`npm publish --tag=${args.type} --otp=${otp}`, execOptions); } else { execSync(`npm publish --otp=${otp}`, execOptions); } } })().catch((e) => console.error(e)); async function ensureCorrectSetup() { if (process.env.CI === 'true') { return; } /** * Ensure remote upstream is set to the correct repo */ try { const upstreamRemote = execSync('git config --get remote.upstream.url') .toString() .trim(); if ( !( upstreamRemote.endsWith(':elastic/eui.git') || // : for SSH, / for HTTPS upstreamRemote.endsWith('/elastic/eui.git') ) ) { console.error( 'Your `upstream` remote must be pointed to https://github.com/elastic/eui.\nPlease run `git remote -v` to ensure you have an `upstream` remote pointed at the correct repo.\n' ); process.exit(1); } } catch { console.error( 'No `upstream` remote found.\nPlease run: `git remote add upstream git@github.com:elastic/eui.git`\n' ); process.exit(1); } /** * Ensure the current branch is clean and pointed at upstream/main */ const branchStatus = execSync('git status -v').toString().trim(); if ( !branchStatus.includes("Your branch is up to date with 'upstream/main'.") ) { // Backports and prereleases do not need to be made from main branch if (!isSpecialRelease) { console.error( 'Your branch is not pointed at "upstream/main". Please ensure your `main` branch is pointed at the correct remote first before proceeding.' ); process.exit(1); } } if (!branchStatus.endsWith('nothing to commit, working tree clean')) { console.error( 'Your staging is not clean. Please stash or check out your local changes before proceeding.' ); process.exit(1); } /** * Ensure latest has been pulled and dependencies are up to date */ if (!isSpecialRelease) execSync('git pull'); execSync('yarn'); } async function getVersionTypeFromChangelog(changelogMap) { // Special releases don't need to check recommended semver if (isSpecialRelease) { console.log( `${chalk.magenta('--type set to')} ${chalk.blue( args.type )}. Creating a special release` ); return args.type; } // @see update-changelog.js const hasFeatures = changelogMap['Features'].length > 0; const hasBugFixes = changelogMap['Bug fixes'].length > 0; const hasBreakingChanges = changelogMap['Breaking changes'].length > 0; // default to a MINOR bump (new features, may have bug fixes, no breaking changes) let recommendedType = TYPE_MINOR; if (hasBugFixes && !hasFeatures) { // there are bug fixes with no minor features recommendedType = TYPE_PATCH; } if (hasBreakingChanges) { // detected breaking changes recommendedType = TYPE_MAJOR; } console.log(chalk.magenta('Detected the following upcoming changelogs:')); console.log(''); Object.entries(changelogMap).forEach(([section, items]) => { console.log(chalk.gray(`${section}: ${items.length}`)); }); console.log(''); console.log( `${chalk.magenta( 'The recommended version update for these changes is' )} ${chalk.blue(recommendedType)}` ); // checking for --type argument value; used by CI to automate releases const versionType = args.type; if (versionType) { // detected version type preference set console.log( `${chalk.magenta('--type argument identifed, set to')} ${chalk.blue( versionType )}` ); if (versionType !== recommendedType) { console.warn( `${chalk.yellow( 'WARNING: --type argument does not match recommended version update' )}` ); } return versionType; } else { console.log( `${chalk.magenta( 'What part of the package version do you want to bump?' )} ${chalk.gray('(major, minor, patch)')}` ); return await promptUserForVersionType(recommendedType); } } async function promptUserForVersionType(recommendedType) { const inquirer = await import('inquirer'); const { versionType } = await inquirer.default.prompt([ { type: 'list', name: 'versionType', message: 'Your choice must be major, minor, or patch', choices: [TYPE_MAJOR, TYPE_MINOR, TYPE_PATCH], default: recommendedType || '', }, ]); return versionType; } async function getOneTimePassword(versionTarget) { console.log( chalk.magenta( `Preparing to publish @elastic/eui@${versionTarget} to npm registry` ) ); console.log(''); console.log( chalk.magenta( 'The @elastic organization requires membership and 2FA to publish' ) ); if (process.env.NPM_OTP) { console.log( chalk.magenta('2FA code provided by NPM_OTP environment variable') ); return process.env.NPM_OTP; } console.log(chalk.magenta('What is your one-time password?')); const inquirer = await import('inquirer'); const { otp } = await inquirer.default.prompt([ { name: 'otp', message: 'Enter password:', validate: (input) => { if (input && !isNaN(input) && input.toString().length >= 6) { return true; } else { return 'Please enter a six-digit numerical 2FA code'; } }, }, ]); return otp; }