ipsambeatae
Version:
Shared dependencies of Compass, the MongoDB extension for VSCode and MongoSH
298 lines (244 loc) • 7.68 kB
text/typescript
/* eslint-disable no-console */
import childProcess from 'child_process';
import { promises as fs } from 'fs';
// @ts-expect-error No definitions available
import gitLogParser from 'git-log-parser';
import path from 'path';
import type { ReleaseType } from 'semver';
import semver from 'semver';
import { PassThrough } from 'stream';
import { isDeepStrictEqual, promisify } from 'util';
import type { GitCommit } from './utils/get-conventional-bump';
import { getConventionalBump } from './utils/get-conventional-bump';
import { getPackagesInTopologicalOrder } from './utils/get-packages-in-topological-order';
import { maxIncrement } from './utils/max-increment';
const execFile = promisify(childProcess.execFile);
const LAST_BUMP_COMMIT_MESSAGE =
process.env.LAST_BUMP_COMMIT_MESSAGE || 'chore(ci): bump packages';
function shouldSkipPackage(packageName: string): boolean {
const skippedPackages = (process.env.SKIP_BUMP_PACKAGES || '').split(',');
const shouldSkip = skippedPackages.includes(packageName);
console.log('Should skip', packageName, {
packages: skippedPackages,
shouldSkip,
});
return shouldSkip;
}
async function main() {
try {
await fs.stat('./package.json');
await fs.stat('.git');
} catch (err: any) {
if (err.code === 'ENOENT') {
throw new Error(
'this command can only be run from the root of a monorepo'
);
}
throw err;
}
const monorepoRootPath = process.cwd();
const packages = await getPackagesInTopologicalOrder(monorepoRootPath);
const newVersions = {};
const range = await getRangeFromLastBump();
for (const packageInfo of packages) {
await processPackage(
path.relative(monorepoRootPath, packageInfo.location),
newVersions,
{ range }
);
}
await execFile('npm', ['install', '--package-lock-only']);
}
main().catch((err) =>
process.nextTick(() => {
throw err;
})
);
async function getCommits({ path, range }: { path: string; range?: string }) {
const commits: GitCommit[] = [];
// PassThrough because gitLogParser returns a legacy stream
const stream = gitLogParser
.parse({
_: [range, path].filter(Boolean),
})
.pipe(new PassThrough({ objectMode: true }));
for await (const commit of stream) {
commits.push(commit);
}
return commits;
}
function updateDeps(
packageJson: Record<string, any>,
newVersions: Record<string, { version: string; bump: ReleaseType }>
) {
console.debug(`[${packageJson.name as string}]`, 'updateDeps', newVersions);
const newPackageJson = JSON.parse(JSON.stringify(packageJson));
let inc: ReleaseType | undefined | null;
for (const sectionName of [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
]) {
const dependenciesSection = newPackageJson[sectionName];
if (!dependenciesSection) {
continue;
}
for (const [depName, { version, bump: depInc }] of Object.entries(
newVersions
)) {
if (!dependenciesSection[depName]) {
console.debug(
`[${packageJson.name as string}]`,
'updateDeps',
sectionName,
depName,
'skipping'
);
continue;
}
console.debug(
`[${packageJson.name as string}]`,
'updateDeps',
sectionName,
depName,
'->',
version
);
const oldDepRange = dependenciesSection[depName];
// we try to preserve some of the ranges specified in the
// original package.json as that may be useful for external dependants.
// The default for ranges we can't recognize is a caret range.
const newDepRange = /^\d\.\d\.\d/.exec(oldDepRange)
? version
: oldDepRange === '*'
? '*'
: oldDepRange.startsWith('~')
? `~${version}`
: `^${version}`;
dependenciesSection[depName] = newDepRange;
// we increment the package version based on the bump on dependencies:
// if a devDependency was bumped, regardless of the increment we increment of a
// patch, otherwise we replicate the increment of the dependency. We always keep
// the biggest increase.
const oldInc = inc;
inc =
sectionName === 'devDependencies'
? maxIncrement('patch', inc)
: maxIncrement(depInc, inc);
console.debug(
`[${packageJson.name as string}]`,
'inc',
sectionName === 'devDependencies',
{
depInc,
oldInc,
newInc: inc,
}
);
}
}
if (inc) {
newPackageJson.version = semver.inc(packageJson.version, inc);
}
console.debug(
`[${packageJson.name as string}]`,
'new package json',
JSON.stringify(newPackageJson)
);
return newPackageJson;
}
async function bumpVersionBasedOnCommits(
packagePath: string,
oldVersion: string,
options: { range?: string }
) {
const commits = await getCommits({
path: packagePath,
range: options.range,
});
console.info(
`[${packagePath}]`,
commits.length,
'commits found in range',
commits.length ? '' : '.. skipping'
);
if (!commits.length) {
return oldVersion;
}
let inc: ReleaseType | null = 'patch';
for (const commit of commits) {
inc = maxIncrement(inc, getConventionalBump(commit));
}
const newVersion = semver.inc(oldVersion, inc!)!;
console.info(
`[${packagePath}]`,
'bumping',
oldVersion,
'to',
newVersion,
`(${inc as string})`
);
return newVersion;
}
async function getRangeFromLastBump() {
const allCommits = await getCommits({
path: '.',
});
console.info('total commits found', allCommits.length);
const lastBumpCommit = allCommits.find((c) =>
c.subject.startsWith(LAST_BUMP_COMMIT_MESSAGE)
);
console.info(
'lastBumpCommit',
lastBumpCommit
? `${lastBumpCommit.commit.long} ${lastBumpCommit.subject}`
: '-'
);
const range = lastBumpCommit
? `${lastBumpCommit.commit.long}...HEAD`
: undefined;
console.info('range:', range);
return range;
}
// walk the dep tree up
async function processPackage(
packagePath: string,
newVersions: Parameters<typeof updateDeps>[1],
options: { range?: string }
) {
const packageJsonPath = path.join(packagePath, 'package.json');
const origPackageJsonString = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(origPackageJsonString);
const packageJsonAfterDepBump = updateDeps(packageJson, newVersions);
let newVersion = packageJson.version;
// if the package is in the skip list we still update its dependencies
// but we keep its version to the old one
if (!shouldSkipPackage(packageJson.name)) {
const conventionalVersion = await bumpVersionBasedOnCommits(
packagePath,
packageJson.version,
options
);
newVersion = semver.gt(conventionalVersion, packageJsonAfterDepBump.version)
? conventionalVersion
: packageJsonAfterDepBump.version;
if (semver.gt(newVersion, packageJson.version)) {
newVersions[packageJson.name] = {
version: newVersion,
bump: semver.diff(newVersion, packageJson.version)!,
};
}
}
const newPackageJson = { ...packageJsonAfterDepBump, version: newVersion };
if (!isDeepStrictEqual(newPackageJson, packageJson)) {
const trailingSpacesMatch = /}(\s*)$/.exec(origPackageJsonString);
const trailingSpaces =
(trailingSpacesMatch && trailingSpacesMatch[1]) || '';
await fs.writeFile(
packageJsonPath,
JSON.stringify(newPackageJson, null, 2) + trailingSpaces,
'utf-8'
);
}
}