diff --git a/packages/cli/package.json b/packages/cli/package.json index 42a33da0db..e119ced65d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -83,6 +83,7 @@ "json-schema": "^0.4.0", "jest-transform-yaml": "^0.1.1", "lodash": "^4.17.21", + "minimatch": "3.0.4", "mini-css-extract-plugin": "^2.4.2", "node-libs-browser": "^2.2.1", "ora": "^5.3.0", @@ -127,6 +128,7 @@ "@types/fs-extra": "^9.0.1", "@types/http-proxy": "^1.17.4", "@types/inquirer": "^8.1.3", + "@types/minimatch": "^3.0.5", "@types/mock-fs": "^4.13.0", "@types/node": "^14.14.32", "@types/recursive-readdir": "^2.2.0", diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index bef5dfc285..ca57b545fd 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -214,11 +214,13 @@ export function registerCommands(program: CommanderStatic) { program .command('versions:bump') + .option('--prefix', 'Override glob for matching packages to upgrade') .description('Bump Backstage packages to the latest versions') .action(lazy(() => import('./versions/bump').then(m => m.default))); program .command('versions:check') + .option('--prefix ', 'Override glob for matching packages to upgrade') .option('--fix', 'Fix any auto-fixable versioning problems') .description('Check Backstage package versioning') .action(lazy(() => import('./versions/lint').then(m => m.default))); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index bc4049f1f4..fe529b0769 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -16,6 +16,7 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; +import { Command } from 'commander'; import { resolve as resolvePath } from 'path'; import { paths } from '../../lib/paths'; import { mapDependencies } from '../../lib/versioning'; @@ -36,6 +37,8 @@ const REGISTRY_VERSIONS: { [name: string]: string } = { '@backstage/core': '1.0.6', '@backstage/core-api': '1.0.7', '@backstage/theme': '2.0.0', + '@backstage-extra/custom': '1.1.0', + '@backstage-extra/custom-two': '2.0.0', }; const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. @@ -83,7 +86,7 @@ describe('bump', () => { it('should bump backstage dependencies', async () => { // Make sure all modules involved in package discovery are in the module cache before we mock fs - await mapDependencies(paths.targetDir); + await mapDependencies(paths.targetDir, '@backstage/*'); mockFs({ '/yarn.lock': lockfileMock, @@ -123,7 +126,7 @@ describe('bump', () => { jest.spyOn(runObj, 'run').mockResolvedValue(undefined); const { log: logs } = await withLogCollector(['log'], async () => { - await bump(); + await bump({ prefix: null } as Command); }); expect(logs.filter(Boolean)).toEqual([ 'Checking for updates of @backstage/theme', @@ -178,9 +181,144 @@ describe('bump', () => { }); }); + it('should bump backstage dependencies and dependencies matching prefix glob', async () => { + // Make sure all modules involved in package discovery are in the module cache before we mock fs + await mapDependencies(paths.targetDir, '@backstage/*'); + const customLockfileMock = `${lockfileMock} +"@backstage-extra/custom@^1.1.0": + version "1.1.0" + +"@backstage-extra/custom@^1.0.1": + version "1.0.1" + +"@backstage-extra/custom-two@^1.0.0": + version "1.0.0" +`; + const customLockfileMockResult = `${HEADER} +"@backstage-extra/custom-two@^1.0.0": + version "1.0.0" + +"@backstage-extra/custom@^1.1.0": + version "1.1.0" + +"@backstage/core@^1.0.5": + version "1.0.6" + dependencies: + "@backstage/core-api" "^1.0.6" + +"@backstage/theme@^1.0.0": + version "1.0.0" +`; + mockFs({ + '/yarn.lock': customLockfileMock, + '/lerna.json': JSON.stringify({ + packages: ['packages/*'], + }), + '/packages/a/package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + '@backstage-extra/custom': '^1.0.1', + '@backstage-extra/custom-two': '^1.0.0', + }, + }), + '/packages/b/package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + '@backstage-extra/custom': '^1.1.0', + '@backstage-extra/custom-two': '^1.0.0', + }, + }), + }); + + paths.targetDir = '/'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...path) => resolvePath('/', ...path)); + jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) => + JSON.stringify({ + type: 'inspect', + data: { + name: name, + 'dist-tags': { + latest: REGISTRY_VERSIONS[name], + }, + }, + }), + ); + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + const { log: logs } = await withLogCollector(['log'], async () => { + await bump({ prefix: '@{backstage,backstage-extra}/*' } as any); + }); + expect(logs.filter(Boolean)).toEqual([ + 'Checking for updates of @backstage/theme', + 'Checking for updates of @backstage-extra/custom-two', + 'Checking for updates of @backstage-extra/custom', + 'Checking for updates of @backstage/core', + 'Checking for updates of @backstage/core-api', + 'Some packages are outdated, updating', + 'unlocking @backstage-extra/custom@^1.0.1 ~> 1.1.0', + 'unlocking @backstage/core@^1.0.3 ~> 1.0.6', + 'unlocking @backstage/core-api@^1.0.6 ~> 1.0.7', + 'unlocking @backstage/core-api@^1.0.3 ~> 1.0.7', + 'bumping @backstage-extra/custom-two in a to ^2.0.0', + 'bumping @backstage/theme in b to ^2.0.0', + 'bumping @backstage-extra/custom-two in b to ^2.0.0', + 'Running yarn install to install new versions', + '⚠️ The following packages may have breaking changes:', + ' @backstage-extra/custom-two : 1.0.0 ~> 2.0.0', + ' @backstage/theme : 1.0.0 ~> 2.0.0', + ' https://github.com/backstage/backstage/blob/master/packages/theme/CHANGELOG.md', + 'Version bump complete!', + ]); + + expect(runObj.runPlain).toHaveBeenCalledTimes(6); + expect(runObj.runPlain).toHaveBeenCalledWith( + 'yarn', + 'info', + '--json', + '@backstage/core', + ); + expect(runObj.runPlain).toHaveBeenCalledWith( + 'yarn', + 'info', + '--json', + '@backstage/theme', + ); + + expect(runObj.run).toHaveBeenCalledTimes(1); + expect(runObj.run).toHaveBeenCalledWith('yarn', ['install']); + + const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + expect(lockfileContents).toEqual(customLockfileMockResult); + + const packageA = await fs.readJson('/packages/a/package.json'); + expect(packageA).toEqual({ + name: 'a', + dependencies: { + '@backstage-extra/custom': '^1.0.1', + '@backstage-extra/custom-two': '^2.0.0', + '@backstage/core': '^1.0.5', // not bumped since new version is within range + }, + }); + const packageB = await fs.readJson('/packages/b/package.json'); + expect(packageB).toEqual({ + name: 'b', + dependencies: { + '@backstage-extra/custom': '^1.1.0', + '@backstage-extra/custom-two': '^2.0.0', + '@backstage/core': '^1.0.3', // not bumped + '@backstage/theme': '^2.0.0', // bumped since newer + }, + }); + }); + it('should ignore not found packages', async () => { // Make sure all modules involved in package discovery are in the module cache before we mock fs - await mapDependencies(paths.targetDir); + await mapDependencies(paths.targetDir, '@backstage/*'); mockFs({ '/yarn.lock': lockfileMockResult, '/lerna.json': JSON.stringify({ @@ -209,7 +347,7 @@ describe('bump', () => { jest.spyOn(runObj, 'run').mockResolvedValue(undefined); const { log: logs } = await withLogCollector(['log'], async () => { - await bump(); + await bump({ prefix: null } as any); }); expect(logs.filter(Boolean)).toEqual([ 'Checking for updates of @backstage/theme', diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 3898c1654a..ce737cd2e3 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -17,6 +17,8 @@ import fs from 'fs-extra'; import chalk from 'chalk'; import semver from 'semver'; +import minimatch from 'minimatch'; +import { Command } from 'commander'; import { isError } from '@backstage/errors'; import { resolve as resolvePath } from 'path'; import { run } from '../../lib/run'; @@ -26,7 +28,7 @@ import { fetchPackageInfo, Lockfile, } from '../../lib/versioning'; -import { includedFilter, forbiddenDuplicatesFilter } from './lint'; +import { forbiddenDuplicatesFilter } from './lint'; import { BACKSTAGE_JSON } from '@backstage/cli-common'; const DEP_TYPES = [ @@ -43,14 +45,19 @@ type PkgVersionInfo = { location: string; }; -export default async () => { +export default async (cmd: Command) => { const lockfilePath = paths.resolveTargetRoot('yarn.lock'); const lockfile = await Lockfile.load(lockfilePath); + let prefix = cmd.prefix; + + if (!prefix) { + prefix = '@backstage/*'; + } const findTargetVersion = createVersionFinder(); // First we discover all Backstage dependencies within our own repo - const dependencyMap = await mapDependencies(paths.targetDir); + const dependencyMap = await mapDependencies(paths.targetDir, prefix); // Next check with the package registry to see which dependency ranges we need to bump const versionBumps = new Map(); @@ -88,13 +95,11 @@ export default async () => { } }); + // Only check @backstage packages and friends, we don't want this to do a full update of all deps + const filter = (name: string) => minimatch(name, prefix); + // Check for updates of transitive backstage dependencies await workerThreads(16, lockfile.keys(), async name => { - // Only check @backstage packages and friends, we don't want this to do a full update of all deps - if (!includedFilter(name)) { - return; - } - let target: string; try { target = await findTargetVersion(name); @@ -237,7 +242,7 @@ export default async () => { // Finally we make sure the new lockfile doesn't have any duplicates const dedupLockfile = await Lockfile.load(lockfilePath); const result = dedupLockfile.analyze({ - filter: includedFilter, + filter, }); if (result.newVersions.length > 0) { diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index dd63e57187..b7f3614fb7 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -82,7 +82,10 @@ describe('mapDependencies', () => { }), }); - const dependencyMap = await mapDependencies(paths.targetDir); + const dependencyMap = await mapDependencies( + paths.targetDir, + '@backstage/*', + ); expect(Array.from(dependencyMap)).toEqual([ [ '@backstage/core', diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index 9f82c560f8..86c7117cfe 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -13,12 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import minimatch from 'minimatch'; import { runPlain } from '../../lib/run'; import { NotFoundError } from '../errors'; -const PREFIX = '@backstage'; - const DEP_TYPES = [ 'dependencies', 'devDependencies', @@ -66,6 +64,7 @@ export async function fetchPackageInfo( /** Map all dependencies in the repo as dependency => dependents */ export async function mapDependencies( targetDir: string, + prefixGlob: string, ): Promise> { const { Project } = require('@lerna/project'); const project = new Project(targetDir); @@ -78,7 +77,7 @@ export async function mapDependencies( ); for (const [name, range] of deps) { - if (name.startsWith(PREFIX)) { + if (minimatch(name, prefixGlob)) { dependencyMap.set( name, (dependencyMap.get(name) ?? []).concat({