UNPKG

@better-builds/lets-version

Version:

A package that reads your conventional commits and git history and recommends (or applies) a SemVer version bump for you

475 lines (474 loc) 23.3 kB
/* eslint-disable @typescript-eslint/no-unused-vars */ import appRootPath from 'app-root-path'; import { execSync } from 'child_process'; import fs from 'fs-extra'; import os from 'os'; import path from 'path'; import prompts from 'prompts'; import semver from 'semver'; import { getChangelogUpdateForPackageInfo, getFormattedChangelogDate } from './changelog.js'; import { fixCWD } from './cwd.js'; import { getBumpRecommendationForPackageInfo, synchronizeBumps } from './dependencies.js'; import { getPackageManager } from './getPackageManager.js'; import { filterPackagesByNames, getAllPackagesChangedBasedOnFilesModified, getPackages } from './getPackages.js'; import { formatVersionTagForPackage, getAllFilesChangedSinceBranch, getAllFilesChangedSinceTagInfos, getLastKnownPublishTagInfoForAllPackages, gitCommit, gitConventionalForAllPackages, gitPush, gitPushTags, gitTag, gitWorkdirUnclean, } from './git.js'; import { buildLocalDependencyGraph } from './localDependencyGraph.js'; import { conventionalCommitToBumpType } from './parser.js'; import { defineLetsVersionConfig, readLetsVersionConfig, } from './readUserConfig.js'; import { BumpType, BumpTypeToString, ChangelogAggregateUpdate, ChangelogEntryType, ChangelogUpdate, ChangelogUpdateEntry, GitConventional, PackageInfo, ReleaseAsPresets, } from './types.js'; import { writePackageJSON } from './writePackageJSON.js'; export { defineLetsVersionConfig }; /** * Returns all detected packages for this repository */ export async function listPackages(opts) { const fixedCWD = fixCWD(opts?.cwd || appRootPath.toString()); return filterPackagesByNames(await getPackages(fixedCWD), undefined, fixedCWD); } /** * Given an optional array of package names, reads the latest * git tag that was used in a previous version bump operation. */ export async function getLastVersionTagsByPackageName(opts) { const { names, noFetchTags = false, cwd = appRootPath.toString() } = opts ?? {}; const fixedCWD = fixCWD(cwd); const filteredPackages = await filterPackagesByNames(await getPackages(fixedCWD), names, fixedCWD); if (!filteredPackages) return []; return getLastKnownPublishTagInfoForAllPackages(filteredPackages, noFetchTags, fixedCWD); } /** * Gets a list of all files that have changed since the last publish for a specific package or set of packages. * If no results are returned, it likely means that there was not a previous version tag detected in git. */ export async function getChangedFilesSinceBump(opts) { const { names, noFetchTags = false, cwd = appRootPath.toString() } = opts ?? {}; const fixedCWD = fixCWD(cwd); const filteredPackages = await filterPackagesByNames(await getPackages(fixedCWD), names, fixedCWD); if (!filteredPackages) return []; const tagResults = await getLastKnownPublishTagInfoForAllPackages(filteredPackages, noFetchTags, fixedCWD); return getAllFilesChangedSinceTagInfos(filteredPackages, tagResults, fixedCWD); } /** * Gets a list of all files that have changed since the current branch was created. */ export async function getChangedFilesSinceBranch(opts) { const { names, cwd = appRootPath.toString(), branch = 'main' } = opts ?? {}; const fixedCWD = fixCWD(cwd); const filteredPackages = await filterPackagesByNames(await getPackages(fixedCWD), names, fixedCWD); if (!filteredPackages) return []; return getAllFilesChangedSinceBranch(filteredPackages, branch, fixedCWD); } /** * Gets a list of all packages that have changed since the last publish for a specific package or set of packages. * If no results are returned, it likely means that there was not a previous version tag detected in git. */ export async function getChangedPackagesSinceBump(opts) { const { names, noFetchTags = false, cwd = appRootPath.toString() } = opts ?? {}; const fixedCWD = fixCWD(cwd); const allPackages = await getPackages(fixedCWD); const rootPackage = allPackages.find(p => p.root); const filteredPackages = await filterPackagesByNames(allPackages, names, fixedCWD); if (!filteredPackages.length) return []; const tagInfos = await getLastKnownPublishTagInfoForAllPackages(filteredPackages, noFetchTags, fixedCWD); const changedFiles = await getAllFilesChangedSinceTagInfos(filteredPackages, tagInfos, fixedCWD); /** @type {PackageInfo[]} */ const allPackagesFilteredPlusRoot = [...filteredPackages]; if (rootPackage) allPackagesFilteredPlusRoot.push(rootPackage); const deduped = Array.from(new Map(allPackagesFilteredPlusRoot.map(p => [p.name, p])).values()); return getAllPackagesChangedBasedOnFilesModified(changedFiles, deduped, fixedCWD); } /** * Gets a list of all packages that have changed since the current branch was created. */ export async function getChangedPackagesSinceBranch(opts) { const { names, cwd = appRootPath.toString(), branch = 'main' } = opts ?? {}; const fixedCWD = fixCWD(cwd); const allPackages = await getPackages(fixedCWD); const rootPackage = allPackages.find(p => p.root); const filteredPackages = await filterPackagesByNames(allPackages, names, fixedCWD); if (!filteredPackages.length) return []; const changedFiles = await getAllFilesChangedSinceBranch(filteredPackages, branch, fixedCWD); /** @type {PackageInfo[]} */ const allPackagesFilteredPlusRoot = [...filteredPackages]; if (rootPackage) allPackagesFilteredPlusRoot.push(rootPackage); const deduped = Array.from(new Map(allPackagesFilteredPlusRoot.map(p => [p.name, p])).values()); return getAllPackagesChangedBasedOnFilesModified(changedFiles, deduped, fixedCWD); } /** * Parses commits since last publish for a specific package or set of packages * and returns them represented as Conventional Commits objects. */ export async function getConventionalCommitsByPackage(opts) { const { commitDateFormat, cwd = appRootPath.toString(), names, noFetchAll = false } = opts ?? {}; const fixedCWD = fixCWD(cwd); const filteredPackages = await filterPackagesByNames(await getPackages(fixedCWD), names, fixedCWD); if (!filteredPackages.length) return []; return gitConventionalForAllPackages({ commitDateFormat, cwd: fixedCWD, packageInfos: filteredPackages, noFetchAll, }); } /** * Given an optional list of package names, parses the git history * since the last bump operation and suggests a bump. * * NOTE: It is possible for your bump recommendation to not change. * If this is the case, this means that your particular package has never had a version bump by the lets-version library. */ export async function getRecommendedBumpsByPackage(opts) { const { commitDateFormat, names, releaseAs = ReleaseAsPresets.AUTO, noFetchAll = false, noFetchTags = false, cwd = appRootPath.toString(), } = opts ?? {}; let preid = opts?.preid || ''; const fixedCWD = fixCWD(cwd); const allPackages = await getPackages(fixedCWD); const filteredPackages = await filterPackagesByNames(allPackages, names, fixedCWD); if (!filteredPackages.length) return { bumps: [], bumpsByPackageName: new Map(), conventional: [], packages: [], }; const conventional = await gitConventionalForAllPackages({ commitDateFormat, cwd: fixedCWD, packageInfos: filteredPackages, noFetchAll, }); const tagsForPackagesMap = new Map((await getLastKnownPublishTagInfoForAllPackages(filteredPackages, noFetchTags, fixedCWD)).map(t => [ t.packageName, t, ])); // we need to gather the commit types per-package, then pick the "greatest" or "most disruptive" one // as the one that will determine the bump to be applied const bumpTypeByPackageName = new Map(); if (preid && releaseAs) { console.warn('Both preid and releaseAs were set. preid takes precedence'); } const isExactRelease = Boolean(semver.coerce(releaseAs)); if (!isExactRelease) { for (const commit of conventional) { // this is the "AUTO" setting, by default, and also the "preid" case let bumpType = Math.max(bumpTypeByPackageName.get(commit.packageInfo.name) ?? BumpType.PATCH, preid ? BumpType.PRERELEASE : conventionalCommitToBumpType(commit)); if (!preid) { switch (releaseAs) { case ReleaseAsPresets.ALPHA: bumpType = BumpType.PRERELEASE; preid = 'alpha'; break; case ReleaseAsPresets.AUTO: /* no-op */ break; case ReleaseAsPresets.BETA: bumpType = BumpType.PRERELEASE; preid = 'beta'; break; case ReleaseAsPresets.MAJOR: bumpType = BumpType.MAJOR; break; case ReleaseAsPresets.MINOR: bumpType = BumpType.MINOR; break; case ReleaseAsPresets.PATCH: bumpType = BumpType.PATCH; break; default: throw new Error(`Unable to getRecommendedBumpsByPackage because an invalid releaseAs of "${releaseAs}" was provided`); } } bumpTypeByPackageName.set(commit.packageInfo.name, bumpType); } } if (isExactRelease) { // loop over all packages and set any packages that don't // already have an entry to a PATCH for (const packageInfo of filteredPackages) { bumpTypeByPackageName.set(packageInfo.name, BumpType.EXACT); } } const updatedOpts = { ...opts, preid, isExactRelease, releaseAs }; const synchronizedBumps = await getSynchronizedBumpsByPackage(updatedOpts, bumpTypeByPackageName, allPackages, tagsForPackagesMap); return { ...synchronizedBumps, conventional }; } export async function getSynchronizedBumpsByPackage(opts, bumpTypeByPackageName, allPackages, tagsForPackagesMap, isExactRelease = false) { const { uniqify = false, saveExact = false, force = false, updatePeer = false, updateOptional = false, preid = '', cwd = appRootPath.toString(), names, releaseAs = ReleaseAsPresets.AUTO, } = opts ?? {}; const fixedCWD = fixCWD(cwd); const filteredPackages = await filterPackagesByNames(allPackages, names, fixedCWD); const filteredPackagesByName = new Map(filteredPackages.map(p => [p.name, p])); const bumps = []; for (const [packageName, bumpType] of bumpTypeByPackageName.entries()) { const packageInfo = filteredPackagesByName.get(packageName); if (!packageInfo) { throw new Error(`No package info for ${packageName} was loaded in memory. Unable to get recommended bump.`); } const tagInfo = tagsForPackagesMap.get(packageName); // preids take precedence above all const from = force || preid || isExactRelease || tagInfo?.sha ? packageInfo.version : null; bumps.push(await getBumpRecommendationForPackageInfo(packageInfo, from, bumpType, undefined, releaseAs, preid, uniqify, fixedCWD)); } const synchronized = await synchronizeBumps(bumps, new Map(bumps.map(b => [b.packageInfo.name, b])), allPackages, releaseAs, preid, uniqify, saveExact, updatePeer, updateOptional, fixedCWD); if (force) { for (const packageInfo of filteredPackages) { // any package that DOESN'T have a bump needs to get one. // leave any that received a bump as-is, for accuracy if (bumpTypeByPackageName.has(packageInfo.name)) continue; let forcedBumpType = BumpType.PATCH; switch (releaseAs) { case ReleaseAsPresets.ALPHA: case ReleaseAsPresets.BETA: forcedBumpType = BumpType.PRERELEASE; break; case ReleaseAsPresets.MAJOR: forcedBumpType = BumpType.MAJOR; break; case ReleaseAsPresets.MINOR: forcedBumpType = BumpType.MINOR; break; case ReleaseAsPresets.PATCH: forcedBumpType = BumpType.PATCH; break; default: break; } bumpTypeByPackageName.set(packageInfo.name, forcedBumpType); } } return { ...synchronized }; } /** * Given an optional list of package names, parses the git history * since the last bump operation, suggest a bump and applies it, also * updating any dependent package.json files across your repository. * * NOTE: It is possible for your bump recommendation to not change. * If this is the case, this means that your particular package has never had a version bump by the lets-version library. */ export async function applyRecommendedBumpsByPackage(opts) { const { allowUncommitted = false, customConfig: customConfigOverride, cwd = appRootPath.toString(), dryRun = false, force = false, names, noChangelog = false, noCommit = false, noFetchAll = false, noFetchTags = false, noInstall = false, noPush = false, preid = '', releaseAs = ReleaseAsPresets.AUTO, rollupChangelog = false, uniqify = false, saveExact = false, updateOptional = false, updatePeer = false, } = opts ?? {}; const fixedCWD = fixCWD(cwd); const customConfig = customConfigOverride ?? (await readLetsVersionConfig(fixedCWD)); let yes = opts?.yes || false; if (dryRun) console.warn('**Dry Run has been enabled**'); if (noCommit && !noPush) console.warn('You supplied --no-commit but not --no-push. This will set --no-push to true to avoid pushing uncommitted changes'); const workingDirUnclean = !allowUncommitted && (await gitWorkdirUnclean(fixedCWD)); if (workingDirUnclean) { console.warn('Unable to apply version bumps because', fixedCWD, 'has uncommitted changes'); return null; } const allPackages = await getPackages(fixedCWD); if (!allPackages.length) return null; const synchronized = await getRecommendedBumpsByPackage({ cwd: fixedCWD, names, releaseAs, preid, uniqify, saveExact, force, noFetchAll, noFetchTags, updatePeer, updateOptional, }); const { bumpsByPackageName: presyncBumpsByPackageName } = synchronized; if (!synchronized.bumps.length) { console.warn('Unable to apply version bumps because no packages need bumping.'); return null; } let requireUserConfirmation = false; const message = synchronized.bumps .map(b => `package: ${b.packageInfo.name}${os.EOL} bump: ${b.from ? `${b.from} -> ${b.to}` : `First time -> ${b.to}`}${os.EOL} type: ${BumpTypeToString[b.type]}${os.EOL} valid: ${b.isValid}${os.EOL} private: ${b.packageInfo.isPrivate}`) .join(`${os.EOL}${os.EOL}`); if (!yes) { requireUserConfirmation = true; const response = await prompts([ { message: `The following bumps will be applied:${os.EOL}${os.EOL}${message}${os.EOL}${os.EOL}Do you want to continue?`, name: 'yes', type: 'confirm', }, ]); yes = response.yes; } if (!yes) { console.warn('User did not confirm changes. Aborting now.'); return null; } // don't want to print the operations message twice, so we track whether a user needed to confirm something if (!requireUserConfirmation) console.info(`Will perform the following updates:${os.EOL}${os.EOL}${message}`); // flush package.json updates out to disk await Promise.all(synchronized.bumps.map(async (b) => { if (dryRun) { return console.info(`Will write package.json updates for ${b.packageInfo.name} to ${b.packageInfo.packageJSONPath}`); } return writePackageJSON(b.packageInfo.pkg, b.packageInfo.packageJSONPath); })); // install deps to ensure lockfiles are updated const pm = await getPackageManager(fixedCWD); if (!noInstall) { if (dryRun) console.info(`Will run ${pm} install to synchronize lockfiles`); else { let didSyncLockFiles = false; /** * As of 5/30/2023, there is an open bug with NPM that causes "npm ci" to fail * due to some internal race condition where lockfiles need a subsequent * npm install to flush out all the changes. * https://github.com/npm/cli/issues/4859#issuecomment-1120018666 * and * https://github.com/npm/cli/issues/4942 */ const syncLockfiles = () => { if (didSyncLockFiles) return; try { execSync(`${pm} install`, { cwd: fixedCWD, stdio: 'inherit' }); didSyncLockFiles = true; } catch (error) { didSyncLockFiles = false; } }; syncLockfiles(); syncLockfiles(); if (!didSyncLockFiles) { console.error('Failed to synchronize lock files. Aborting remaining operations'); process.exit(1); } } } // generate changelogs if (!noChangelog) { // there may be packages that now need to have changelogs updated // because they're being bumped as the result of dep tree updates. // we need to apply some additional changelogs if that's the casue const changelogInfo = await getChangelogUpdateForPackageInfo({ commits: synchronized.conventional, bumps: synchronized.bumps, lineFormatter: customConfig?.changelog?.changelogLineFormatter, }); for (const syncbump of synchronized.bumps) { if (presyncBumpsByPackageName.has(syncbump.packageInfo.name)) continue; changelogInfo.push(new ChangelogUpdate(getFormattedChangelogDate(), syncbump, { [ChangelogEntryType.MISC]: new ChangelogUpdateEntry(ChangelogEntryType.MISC, [ new GitConventional({ body: null, breaking: syncbump.type === BumpType.MAJOR, footer: null, header: 'Version bump due to parent version bump', mentions: null, merge: null, notes: [], sha: '', }), ], customConfig?.changelog?.changelogLineFormatter), })); } // actually write the changelogs await Promise.all(changelogInfo.map(async (c) => { let existingChangelog = ''; const changelogDir = path.dirname(c.changelogPath); await fs.ensureDir(changelogDir); try { existingChangelog = await fs.readFile(c.changelogPath, 'utf-8'); } catch (error) { /* file doesn't exist */ } const changelogUpdates = customConfig?.changelog?.changeLogEntryFormatter?.(c, changelogInfo) ?? `${c.toString()}${os.EOL}---${os.EOL}${os.EOL}`; if (dryRun) { console.info(`Will write the following changelog update to ${c.changelogPath}:${os.EOL}${os.EOL}${changelogUpdates}`); } else await fs.writeFile(c.changelogPath, `${changelogUpdates}${existingChangelog}`, 'utf-8'); })); if (rollupChangelog) { // User wants an aggregated changelog at the root. // if this repo only has a single package AND that package is marked // as the root package, do nothing const continueWithRollupChangelog = (synchronized.packages.length === 1 && !synchronized.packages[0]?.root) || synchronized.packages.length > 1; if (continueWithRollupChangelog) { const changelogUpdates = new ChangelogAggregateUpdate(fixedCWD, getFormattedChangelogDate(), changelogInfo); if (!dryRun) await fs.ensureFile(changelogUpdates.changelogPath); let existingChangelog = ''; try { existingChangelog = await fs.readFile(changelogUpdates.changelogPath, 'utf-8'); } catch (error) { /* no-op */ } const updatesToWrite = customConfig?.changelog?.changeLogRollupFormatter ? customConfig?.changelog?.changeLogRollupFormatter(changelogUpdates) : changelogUpdates.toString(); if (dryRun) { console.info(`Will write the following rollup changelog updated to ${changelogUpdates.changelogPath}:${os.EOL}${os.EOL}${updatesToWrite || ''}`); } else if (updatesToWrite) { await fs.writeFile(changelogUpdates.changelogPath, `${updatesToWrite}${existingChangelog}`, 'utf-8'); } } } } // commit the stuffs if (!noCommit) { const header = 'Version Bump'; const body = synchronized.bumps.map(b => `${b.packageInfo.name}@${b.to}`).join(os.EOL); if (dryRun) { console.info(`~~~~~${os.EOL}Will create a git commit with the following message:${os.EOL}${os.EOL}${header}${os.EOL}${os.EOL}${body}${os.EOL}~~~~~`); } else await gitCommit(header, body, '', fixedCWD); } // create all the git tags let tagsToPush = []; if (!noCommit) { tagsToPush = await Promise.all(synchronized.bumps.map(async (b) => { const tag = formatVersionTagForPackage(new PackageInfo({ ...b.packageInfo, version: b.to, })); if (dryRun) console.info(`Will create the following git tag: ${tag}`); else await gitTag(tag, fixedCWD); return tag; }, fixedCWD)); } if (!noPush) { // push to upstream if (dryRun) console.info(`Will git push --no-verify all changes made during the version bump operation`); else await gitPush(fixedCWD); if (dryRun) console.info(`Will push the following git tags: ${tagsToPush.join(' ')}`); else if (tagsToPush.length) await gitPushTags(tagsToPush, fixedCWD); } return synchronized; } /** * Builds a local repository-only dependency graph. If you are in a monorepo, this is useful to visualize how the dependencies in said monorepo relate to each other. */ export async function localDepGraph(cwd = appRootPath.toString()) { const fixedCWD = fixCWD(cwd); const allPackages = await getPackages(fixedCWD); return buildLocalDependencyGraph(allPackages); }