From fb5a6e23fa923c1f51fe2ba975c3d465d6fdd7d1 Mon Sep 17 00:00:00 2001 From: Eduardo San Martin Morote Date: Wed, 27 Nov 2024 15:49:14 +0100 Subject: [PATCH] build: update release script --- package.json | 2 +- pnpm-lock.yaml | 30 +++-- scripts/release.mjs | 316 +++++++++++++++++++++++++++++--------------- 3 files changed, 225 insertions(+), 123 deletions(-) diff --git a/package.json b/package.json index 642eedb230..fa2e6853ef 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "postinstall": "simple-git-hooks" }, "devDependencies": { + "@posva/prompts": "^2.4.4", "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-node-resolve": "^15.2.3", @@ -42,7 +43,6 @@ "@vue/server-renderer": "~3.5.13", "chalk": "^5.3.0", "conventional-changelog-cli": "^2.2.2", - "enquirer": "^2.4.1", "execa": "^9.5.1", "globby": "^14.0.1", "happy-dom": "^15.11.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15318d1212..6f7dbea4db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: devDependencies: + '@posva/prompts': + specifier: ^2.4.4 + version: 2.4.4 '@rollup/plugin-alias': specifier: ^5.1.0 version: 5.1.1(rollup@4.27.3) @@ -51,9 +54,6 @@ importers: conventional-changelog-cli: specifier: ^2.2.2 version: 2.2.2 - enquirer: - specifier: ^2.4.1 - version: 2.4.1 execa: specifier: ^9.5.1 version: 9.5.1 @@ -1359,6 +1359,10 @@ packages: '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + '@posva/prompts@2.4.4': + resolution: {integrity: sha512-8aPwklhbSV2VN/NQMBNFkuo8+hlJVdcFRXp4NCIfdcahh3qNEcaSoD8qXjru0OlN1sONJ7le7p6+YUbALaG6Mg==} + engines: {node: '>= 14'} + '@redocly/ajv@8.11.2': resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} @@ -2642,10 +2646,6 @@ packages: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} engines: {node: '>=10.13.0'} - enquirer@2.4.1: - resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} - engines: {node: '>=8.6'} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3324,6 +3324,10 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + klona@2.0.6: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} @@ -6463,6 +6467,11 @@ snapshots: '@polka/url@1.0.0-next.28': {} + '@posva/prompts@2.4.4': + dependencies: + kleur: 4.1.5 + sisteransi: 1.0.5 + '@redocly/ajv@8.11.2': dependencies: fast-deep-equal: 3.1.3 @@ -7870,11 +7879,6 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.1 - enquirer@2.4.1: - dependencies: - ansi-colors: 4.1.3 - strip-ansi: 6.0.1 - entities@4.5.0: {} environment@1.1.0: {} @@ -8655,6 +8659,8 @@ snapshots: kleur@3.0.3: {} + kleur@4.1.5: {} + klona@2.0.6: {} knitwork@1.1.0: {} diff --git a/scripts/release.mjs b/scripts/release.mjs index 42146787ad..3b01f0697e 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -1,21 +1,20 @@ -import minimist from 'minimist' import fs from 'node:fs/promises' -import { join, resolve, dirname } from 'node:path' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' +import minimist from 'minimist' import chalk from 'chalk' import semver from 'semver' -import enquirer from 'enquirer' +import prompts from '@posva/prompts' import { execa } from 'execa' import pSeries from 'p-series' import { globby } from 'globby' -const { prompt } = enquirer - const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const args = minimist(process.argv.slice(2)) -let { +const { skipBuild, tag: optionTag, dry: isDryRun, @@ -45,24 +44,30 @@ Flags: } // const preId = -// args.preid || +// args.preId || // (semver.prerelease(currentVersion) && semver.prerelease(currentVersion)[0]) const EXPECTED_BRANCH = 'v2' +// this package will use tags like v1.0.0 while the rest will use the full package name like @pinia/testing@1.0.0 +const MAIN_PKG_NAME = 'pinia' -const bin = (name) => resolve(__dirname, '../node_modules/.bin/' + name) /** - * @param bin {string} - * @param args {string} - * @param opts {import('execa').CommonOptions} - * @returns + * @type {typeof execa} */ const run = (bin, args, opts = {}) => execa(bin, args, { stdio: 'inherit', ...opts }) +/** + * @param bin {string} + * @param args {string[]} + * @param opts {import('execa').Options} + */ const dryRun = (bin, args, opts = {}) => - console.log(chalk.blue(`[dryrun] ${bin} ${args.join(' ')}`), opts) + console.log(chalk.blue(`[dry-run] ${bin} ${args.join(' ')}`), opts) const runIfNotDry = isDryRun ? dryRun : run -const getPkgRoot = (pkg) => resolve(__dirname, '../packages/' + pkg) -const step = (msg) => console.log(chalk.cyan(msg)) + +/** + * @param msg {string[]} + */ +const step = (...msg) => console.log(chalk.cyan(...msg)) async function main() { if (!skipCleanGitCheck) { @@ -107,33 +112,44 @@ async function main() { } } - const changedPackages = await getChangedPackages() + const packagesFolders = [ + join(__dirname, '../packages/pinia'), + join(__dirname, '../packages/testing'), + join(__dirname, '../packages/nuxt'), + ] + + const changedPackages = await getChangedPackages(...packagesFolders) + if (!changedPackages.length) { console.log(chalk.red(`No packages have changed since last release`)) return } if (isDryRun) { - console.log('\n' + chalk.bold.blue('This is a dry run') + '\n') + console.log(`\n${chalk.bold.blue('This is a dry run')}\n`) } - // NOTE: I'm unsure if this would mess up the changelog - // const { pickedPackages } = await prompt({ - // type: 'multiselect', - // name: 'pickedPackages', - // messages: 'What packages do you want to release?', - // choices: changedPackages.map((pkg) => pkg.name), - // }) + // allow to select which packages + const { pickedPackages } = await prompts({ + type: 'multiselect', + name: 'pickedPackages', + message: 'What packages do you want to release?', + instructions: false, + min: 1, + choices: changedPackages.map((pkg) => ({ + title: pkg.name, + value: pkg.name, + selected: true, + })), + }) - const packagesToRelease = changedPackages - // const packagesToRelease = changedPackages.filter((pkg) => - // pickedPackages.includes(pkg.name) - // ) + // const packagesToRelease = changedPackages + const packagesToRelease = changedPackages.filter((pkg) => + pickedPackages.includes(pkg.name) + ) step( - `Ready to release ${packagesToRelease - .map(({ name }) => chalk.bold.white(name)) - .join(', ')}` + `Ready to release ${packagesToRelease.map(({ name }) => chalk.bold.white(name)).join(', ')}` ) const pkgWithVersions = await pSeries( @@ -150,31 +166,46 @@ async function main() { ...(preId ? ['prepatch', 'preminor', 'premajor', 'prerelease'] : []), ] - const { release } = await prompt({ + const betaVersion = semver.inc(version, 'prerelease', 'beta') + + const { release } = await prompts({ type: 'select', name: 'release', message: `Select release type for ${chalk.bold.white(name)}`, choices: versionIncrements - .map((i) => `${i}: ${name} (${semver.inc(version, i, preId)})`) + .map((release) => { + const newVersion = semver.inc(version, release, preId) + return { + value: newVersion, + title: `${release}: ${name} (${newVersion})`, + } + }) .concat( optionTag === 'beta' - ? [`beta: ${name} (${semver.inc(version, 'prerelease', 'beta')})`] + ? [ + { + title: `beta: ${name} (${betaVersion})`, + value: betaVersion, + }, + ] : [] ) - .concat(['custom']), + .concat([{ value: 'custom', title: 'custom' }]), }) + console.log(release) + if (release === 'custom') { version = ( - await prompt({ - type: 'input', + await prompts({ + type: 'text', name: 'version', message: `Input custom version (${chalk.bold.white(name)})`, initial: version, }) ).version } else { - version = release.match(/\((.*)\)/)[1] + version = release } if (!semver.valid(version)) { @@ -185,20 +216,21 @@ async function main() { }) ) - // TODO: we need to reorder packages based on dependencies - // pinia needs to be first - const piniaPkgIndx = packagesToRelease.find(({ name }) => name === 'pinia') - if (piniaPkgIndx > 0) { - packagesToRelease.unshift(packagesToRelease.splice(piniaPkgIndx, 1)[0]) + // put the main package first as others might depend on it + const mainPkgIndex = packagesToRelease.find( + ({ name }) => name === MAIN_PKG_NAME + ) + if (mainPkgIndex > 0) { + packagesToRelease.unshift(packagesToRelease.splice(mainPkgIndex, 1)[0]) } - const { yes: isReleaseConfirmed } = await prompt({ + const { yes: isReleaseConfirmed } = await prompts({ type: 'confirm', name: 'yes', message: `Releasing \n${pkgWithVersions .map( ({ name, version }) => - ` · ${chalk.white(name)}: ${chalk.yellow.bold('v' + version)}` + ` · ${chalk.white(name)}: ${chalk.yellow.bold(`v${version}`)}` ) .join('\n')}\nConfirm?`, }) @@ -216,22 +248,53 @@ async function main() { } step('\nGenerating changelogs...') - for (const pkg of pkgWithVersions) { - step(` -> ${pkg.name} (${pkg.path})`) - await runIfNotDry(`pnpm`, ['run', 'changelog'], { cwd: pkg.path }) - await runIfNotDry(`pnpm`, ['exec', 'prettier', '--write', 'CHANGELOG.md'], { - cwd: pkg.path, + await Promise.all( + pkgWithVersions.map(async (pkg) => { + step(` -> ${pkg.name} (${pkg.path})`) + const changelogExists = existsSync(join(pkg.path, 'CHANGELOG.md')) + + if (!changelogExists) { + console.log(chalk.yellow(`No CHANGELOG.md found in ${pkg.name}`)) + } + + await runIfNotDry( + `pnpm`, + [ + 'exec', + 'conventional-changelog', + '-i', + 'CHANGELOG.md', + '--same-file', + '-p', + 'conventionalcommits', + '-r', + changelogExists ? '1' : '0', + '--commit-path', + '.', + '--lerna-package', + pkg.name, + ...(pkg.name === MAIN_PKG_NAME + ? [] + : ['--tag-prefix', `${pkg.name}@`]), + ], + { cwd: pkg.path } + ) + await runIfNotDry( + `pnpm`, + ['exec', 'prettier', '--write', 'CHANGELOG.md'], + { + cwd: pkg.path, + } + ) + // NOTE: pnpm publish automatically copies the LICENSE file }) - await fs.copyFile( - resolve(__dirname, '../LICENSE'), - resolve(pkg.path, 'LICENSE') - ) - } + ) - const { yes: isChangelogCorrect } = await prompt({ + const { yes: isChangelogCorrect } = await prompts({ type: 'confirm', name: 'yes', message: 'Are the changelogs correct?', + initial: true, }) if (!isChangelogCorrect) { @@ -253,24 +316,21 @@ async function main() { 'add', 'packages/*/CHANGELOG.md', 'packages/*/package.json', - 'pnpm-lock.yaml', ]) await runIfNotDry('git', [ 'commit', '-m', - `release: ${pkgWithVersions - .map(({ name, version }) => `${name}@${version}`) - .join(' ')}`, + `release: ${pkgWithVersions.map(({ name, version }) => `${name}@${version}`).join(' ')}`, ]) } else { console.log('No changes to commit.') } step('\nCreating tags...') - let versionsToPush = [] + const versionsToPush = [] for (const pkg of pkgWithVersions) { const tagName = - pkg.name === 'vue-router' + pkg.name === MAIN_PKG_NAME ? `v${pkg.version}` : `${pkg.name}@${pkg.version}` @@ -294,7 +354,7 @@ async function main() { /** * - * @param packageList {{ name: string; path: string; version: string, pkg: any }} + * @param packageList {{ name: string; path: string; version: string, pkg: any }[]} */ async function updateVersions(packageList) { return Promise.all( @@ -304,7 +364,7 @@ async function updateVersions(packageList) { updateDeps(pkg, 'dependencies', packageList) updateDeps(pkg, 'peerDependencies', packageList) } - const content = JSON.stringify(pkg, null, 2) + '\n' + const content = `${JSON.stringify(pkg, null, 2)}\n` return isDryRun ? dryRun('write', [name], { version: pkg.version, @@ -337,7 +397,7 @@ function updateDeps(pkg, depType, updatedPackages) { `${pkg.name} -> ${depType} -> ${dep}@>=${updatedDep.version}` ) ) - deps[dep] = '>=' + updatedDep.version + deps[dep] = `>=${updatedDep.version}` } } }) @@ -355,7 +415,7 @@ async function publishPackage(pkg) { ...(skipCleanGitCheck ? ['--no-git-checks'] : []), '--access', 'public', - // specific to pinia + // only needed for branches other than main '--publish-branch', EXPECTED_BRANCH, ], @@ -377,65 +437,101 @@ async function publishPackage(pkg) { } /** - * Get the packages that have changed. Based on `lerna changed` but without lerna. + * Get the last tag published for a package or null if there are no tags * - * @returns {Promise<{ name: string; path: string; pkg: any; version: string }[]} + * @param {string} pkgName - package name + * @returns {string} the last tag or full commit hash */ -async function getChangedPackages() { - let lastTag - +async function getLastTag(pkgName) { try { - const { stdout } = await run('git', ['describe', '--tags', '--abbrev=0'], { - stdio: 'pipe', - }) - lastTag = stdout + const { stdout } = await run( + 'git', + [ + 'describe', + '--tags', + '--abbrev=0', + '--match', + pkgName === MAIN_PKG_NAME ? 'v*' : `${pkgName}@*`, + ], + { + stdio: 'pipe', + } + ) + + return stdout } catch (error) { - // maybe there are no tags - console.error(`Couldn't get the last tag, using first commit...`) + console.log( + chalk.dim( + `Couldn't get "${chalk.bold(pkgName)}" last tag, using first commit...` + ) + ) + + // 128 is the git exit code when there is nothing to describe + if (error.exitCode !== 128) { + console.error(error) + } const { stdout } = await run( 'git', ['rev-list', '--max-parents=0', 'HEAD'], { stdio: 'pipe' } ) - lastTag = stdout + return stdout } - // globby expects `/` even on windows - const folders = await globby( - join(__dirname, '../packages/*').replace(/\\/g, '/'), - { - onlyFiles: false, - } - ) +} +/** + * Get the packages that have changed. Based on `lerna changed` but without lerna. + * + * @param {string[]} folders + * @returns {Promise<{ name: string; path: string; pkg: any; version: string; start: string }[]} a promise of changed packages + */ +async function getChangedPackages(...folders) { const pkgs = await Promise.all( folders.map(async (folder) => { - if (!(await fs.lstat(folder)).isDirectory()) return null - - const pkg = JSON.parse(await fs.readFile(join(folder, 'package.json'))) - if (!pkg.private) { - const { stdout: hasChanges } = await run( - 'git', - [ - 'diff', - lastTag, - '--', - // apparently {src,package.json} doesn't work - join(folder, 'src'), - join(folder, 'package.json'), - ], - { stdio: 'pipe' } - ) + if (!(await fs.lstat(folder)).isDirectory()) { + console.warn(chalk.dim(`Skipping "${folder}" as it is not a directory`)) + return null + } - if (hasChanges) { - return { - path: folder, - name: pkg.name, - version: pkg.version, - pkg, - } - } else { - return null + const pkg = JSON.parse( + await fs.readFile(join(folder, 'package.json'), 'utf-8') + ) + if (pkg.private) { + console.info(chalk.dim(`Skipping "${pkg.name}" it's private`)) + return null + } + + const lastTag = await getLastTag(pkg.name) + + const { stdout: hasChanges } = await run( + 'git', + [ + 'diff', + lastTag, + '--', + // apparently {src,package.json} doesn't work + join(folder, 'src'), + // TODO: should not check dev deps and should compare to last tag changes + join(folder, 'package.json'), + ], + { stdio: 'pipe' } + ) + + if (hasChanges) { + return { + path: folder, + name: pkg.name, + version: pkg.version, + pkg, + start: lastTag, } + } else { + console.warn( + chalk.dim( + `Skipping "${pkg.name}" as it has no changes since last release` + ) + ) + return null } }) )