From 0145faa6ee2faf8d4938c7d518f053a634009811 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 14:20:13 +0100 Subject: [PATCH 01/11] cli: add Lockfile utility --- packages/cli/package.json | 3 + .../cli/src/lib/versioning/Lockfile.test.ts | 150 +++++++++++ packages/cli/src/lib/versioning/Lockfile.ts | 242 ++++++++++++++++++ packages/cli/src/lib/versioning/index.ts | 17 ++ yarn.lock | 10 + 5 files changed, 422 insertions(+) create mode 100644 packages/cli/src/lib/versioning/Lockfile.test.ts create mode 100644 packages/cli/src/lib/versioning/Lockfile.ts create mode 100644 packages/cli/src/lib/versioning/index.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index ec3aaacdee..eb56b19971 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,6 +51,7 @@ "@types/webpack-node-externals": "^2.5.0", "@typescript-eslint/eslint-plugin": "^v3.10.1", "@typescript-eslint/parser": "^v3.10.1", + "@yarnpkg/lockfile": "^1.1.0", "bfj": "^7.0.2", "chalk": "^4.0.0", "chokidar": "^3.3.1", @@ -92,6 +93,7 @@ "rollup-plugin-postcss": "^3.1.1", "rollup-plugin-typescript2": "^0.27.3", "rollup-pluginutils": "^2.8.2", + "semver": "^7.3.2", "start-server-webpack-plugin": "^2.2.5", "style-loader": "^1.2.1", "sucrase": "^3.16.0", @@ -131,6 +133,7 @@ "@types/tar": "^4.0.3", "@types/webpack": "^4.41.7", "@types/webpack-dev-server": "^3.11.0", + "@types/yarnpkg__lockfile": "^1.1.4", "del": "^5.1.0", "mock-fs": "^4.13.0", "nodemon": "^2.0.2", diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts new file mode 100644 index 0000000000..5459ed53fb --- /dev/null +++ b/packages/cli/src/lib/versioning/Lockfile.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import mockFs from 'mock-fs'; +import { Lockfile } from './Lockfile'; + +const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +`; + +const mockA = `${HEADER} +a@^1: + version "1.0.1" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + dependencies: + b "^2" + +b@2.0.x: + version "2.0.1" + +b@^2: + version "2.0.0" +`; + +const mockADedup = `${HEADER} +a@^1: + version "1.0.1" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + dependencies: + b "^2" + +b@2.0.x, b@^2: + version "2.0.1" +`; + +const mockB = `${HEADER} +"@s/a@*", "@s/a@1 || 2", "@s/a@^1": + version "1.0.1" + +"@s/a@^2.0.x": + version "2.0.0" +`; + +const mockBDedup = `${HEADER} +"@s/a@*", "@s/a@1 || 2", "@s/a@^2.0.x": + version "2.0.0" + +"@s/a@^1": + version "1.0.1" +`; + +describe('Lockfile', () => { + afterEach(() => { + mockFs.restore(); + }); + + it('should load and serialize mockA', async () => { + mockFs({ + '/yarn.lock': mockA, + }); + + const lockfile = await Lockfile.load('/yarn.lock'); + expect(lockfile.get('a')).toEqual([{ range: '^1', version: '1.0.1' }]); + expect(lockfile.get('b')).toEqual([ + { range: '2.0.x', version: '2.0.1' }, + { range: '^2', version: '2.0.0' }, + ]); + expect(lockfile.toString()).toBe(mockA); + }); + + it('should deduplicate mockA', async () => { + mockFs({ + '/yarn.lock': mockA, + }); + + const lockfile = await Lockfile.load('/yarn.lock'); + const result = lockfile.analyze(); + expect(result).toEqual({ + invalidRanges: [], + newRanges: [], + newVersions: [ + { + name: 'b', + range: '^2', + oldVersion: '2.0.0', + newVersion: '2.0.1', + }, + ], + }); + + expect(lockfile.toString()).toBe(mockA); + lockfile.replaceVersions(result.newVersions); + expect(lockfile.toString()).toBe(mockADedup); + }); + + it('should deduplicate mockB', async () => { + mockFs({ + '/yarn.lock': mockB, + }); + + const lockfile = await Lockfile.load('/yarn.lock'); + const result = lockfile.analyze(); + expect(result).toEqual({ + invalidRanges: [], + newRanges: [ + { + name: '@s/a', + oldRange: '^1', + newRange: '*', + oldVersion: '1.0.1', + newVersion: '2.0.0', + }, + ], + newVersions: [ + { + name: '@s/a', + range: '*', + oldVersion: '1.0.1', + newVersion: '2.0.0', + }, + { + name: '@s/a', + range: '1 || 2', + oldVersion: '1.0.1', + newVersion: '2.0.0', + }, + ], + }); + + expect(lockfile.toString()).toBe(mockB); + lockfile.replaceVersions(result.newVersions); + expect(lockfile.toString()).toBe(mockBDedup); + }); +}); diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts new file mode 100644 index 0000000000..e03fa8ec3f --- /dev/null +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -0,0 +1,242 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import semver from 'semver'; +import { + parse as parseLockfile, + stringify as stringifyLockfile, +} from '@yarnpkg/lockfile'; + +const ENTRY_PATTERN = /^((?:@[^/]+\/)?[^@/]+)@(.+)$/; + +type LockfileData = { + [entry: string]: { + version: string; + resolved?: string; + integrity?: string; + dependencies?: { [name: string]: string }; + }; +}; + +type LockfileQueryEntry = { + range: string; + version: string; +}; + +/** Entries that have an invalid version range, for example an NPM tag */ +type AnalyzeResultInvalidRange = { + name: string; + range: string; +}; + +/** Entries that can be deduplicated by bumping to an existing higher version */ +type AnalyzeResultNewVersion = { + name: string; + range: string; + oldVersion: string; + newVersion: string; +}; + +/** Entries that {would need a dependency update in package.json to be deduplicated */ +type AnalyzeResultNewRange = { + name: string; + oldRange: string; + newRange: string; + oldVersion: string; + newVersion: string; +}; + +type AnalyzeResult = { + invalidRanges: AnalyzeResultInvalidRange[]; + newVersions: AnalyzeResultNewVersion[]; + newRanges: AnalyzeResultNewRange[]; +}; + +export class Lockfile { + static async load(path: string) { + const lockfileContents = await fs.readFile(path, 'utf8'); + const lockfile = parseLockfile(lockfileContents); + if (lockfile.type !== 'success') { + throw new Error(`Failed yarn.lock parse with ${lockfile.type}`); + } + + const data = lockfile.object as LockfileData; + const packages = new Map(); + + for (const [key, value] of Object.entries(data)) { + const [, name, range] = ENTRY_PATTERN.exec(key) ?? []; + if (!name) { + throw new Error(`Failed to parse yarn.lock entry '${key}'`); + } + + let queries = packages.get(name); + if (!queries) { + queries = []; + packages.set(name, queries); + } + queries.push({ range, version: value.version }); + } + + return new Lockfile(packages, data); + } + + constructor( + private readonly packages: Map, + private readonly data: LockfileData, + ) {} + + get(name: string): LockfileQueryEntry[] | undefined { + return this.packages.get(name); + } + + /** Analyzes the lockfile to identify possible actions and warnings for the entries */ + analyze(options?: { filter?: (name: string) => boolean }): AnalyzeResult { + const { filter } = options ?? {}; + const result: AnalyzeResult = { + invalidRanges: [], + newVersions: [], + newRanges: [], + }; + + for (const [name, allEntries] of this.packages) { + if (filter && !filter(name)) { + continue; + } + + // Get rid of an signal any invalid ranges upfront + const invalid = allEntries.filter(e => !semver.validRange(e.range)); + result.invalidRanges.push( + ...invalid.map(({ range }) => ({ name, range })), + ); + + // Grab all valid entries, if there isn't at least 2 different valid ones we're done + const entries = allEntries.filter(e => semver.validRange(e.range)); + if (entries.length < 2) { + continue; + } + + // Find all versions currently in use + const versions = Array.from( + new Set(entries.map(e => e.version)), + ).sort((v1, v2) => semver.rcompare(v1, v2)); + + // If we're not using at least 2 different versions we're done + if (versions.length < 2) { + continue; + } + + const acceptedVersions = new Set(); + for (const { version, range } of entries) { + // Finds the highest matching version from the the known versions + // TODO(Rugvip): We may want to select the version that satisfies the most ranges rather than the highest one + const acceptedVersion = versions.find(v => semver.satisfies(v, range)); + if (!acceptedVersion) { + throw new Error( + `No existing version was accepted for range ${range}, searching through ${versions}`, + ); + } + + if (acceptedVersion !== version) { + result.newVersions.push({ + name, + range, + newVersion: acceptedVersion, + oldVersion: version, + }); + } + + acceptedVersions.add(acceptedVersion); + } + + // If all ranges where able to accept the same version, we're done + if (acceptedVersions.size === 1) { + continue; + } + + // Find the max version and range that we may want bump older packages to + const maxVersion = Array.from(acceptedVersions).sort(semver.rcompare)[0]; + const maxEntry = entries.find(e => semver.satisfies(maxVersion, e.range)); + if (!maxEntry) { + throw new Error( + `No entry found that satisfies max version '${maxVersion}'`, + ); + } + + // Find all entries that don't satisfy the max version + for (const { version, range } of entries) { + if (semver.satisfies(maxVersion, range)) { + continue; + } + + result.newRanges.push({ + name, + oldRange: range, + newRange: maxEntry.range, + oldVersion: version, + newVersion: maxVersion, + }); + } + } + + return result; + } + + /** Modifies the lockfile by bumping packages to the suggested versions */ + replaceVersions(results: AnalyzeResultNewVersion[]) { + for (const { name, range, oldVersion, newVersion } of results) { + const query = `${name}@${range}`; + + // Update the backing data + const entryData = this.data[query]; + if (!entryData) { + throw new Error(`No entry data for ${query}`); + } + if (entryData.version !== oldVersion) { + throw new Error( + `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`, + ); + } + + // Modifying the data in the entry is not enough, we need to reference an existing version object + const matchingEntry = Object.entries(this.data).find( + ([q, e]) => q.startsWith(`${name}@`) && e.version === newVersion, + ); + if (!matchingEntry) { + throw new Error( + `No matching entry found for ${name} at version ${newVersion}`, + ); + } + this.data[query] = matchingEntry[1]; + + // Update out internal data structure + const entry = this.packages.get(name)?.find(e => e.range === range); + if (!entry) { + throw new Error(`No entry data for ${query}`); + } + if (entry.version !== oldVersion) { + throw new Error( + `Expected existing version data for ${query} to be ${oldVersion}, was ${entryData.version}`, + ); + } + entry.version = newVersion; + } + } + + toString() { + return stringifyLockfile(this.data); + } +} diff --git a/packages/cli/src/lib/versioning/index.ts b/packages/cli/src/lib/versioning/index.ts new file mode 100644 index 0000000000..6f0a649ee4 --- /dev/null +++ b/packages/cli/src/lib/versioning/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Lockfile } from './Lockfile'; diff --git a/yarn.lock b/yarn.lock index a90d885927..92aecbba36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6088,6 +6088,11 @@ dependencies: "@types/yargs-parser" "*" +"@types/yarnpkg__lockfile@^1.1.4": + version "1.1.4" + resolved "https://registry.npmjs.org/@types/yarnpkg__lockfile/-/yarnpkg__lockfile-1.1.4.tgz#445251eb00bd9c1e751f82c7c6bf4f714edfd464" + integrity sha512-/emrKCfQMQmFCqRqqBJ0JueHBT06jBRM3e8OgnvDUcvuExONujIk2hFA5dNsN9Nt41ljGVDdChvCydATZ+KOZw== + "@types/yup@^0.29.8": version "0.29.8" resolved "https://registry.npmjs.org/@types/yup/-/yup-0.29.8.tgz#83db15735987db9fe5a38772a0fb9500e3c5bf39" @@ -6384,6 +6389,11 @@ resolved "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + "@zkochan/cmd-shim@^3.1.0": version "3.1.0" resolved "https://registry.npmjs.org/@zkochan/cmd-shim/-/cmd-shim-3.1.0.tgz#2ab8ed81f5bb5452a85f25758eb9b8681982fd2e" From 4c642721376961ecc06c9abde42ba70534d9b63c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 14:48:12 +0100 Subject: [PATCH 02/11] cli: make Lockfile analyze pick the most specific new version range --- packages/cli/src/lib/versioning/Lockfile.test.ts | 2 +- packages/cli/src/lib/versioning/Lockfile.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts index 5459ed53fb..20847242d3 100644 --- a/packages/cli/src/lib/versioning/Lockfile.test.ts +++ b/packages/cli/src/lib/versioning/Lockfile.test.ts @@ -122,7 +122,7 @@ describe('Lockfile', () => { { name: '@s/a', oldRange: '^1', - newRange: '*', + newRange: '^2.0.x', oldVersion: '1.0.1', newVersion: '2.0.0', }, diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts index e03fa8ec3f..8acb387704 100644 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -167,9 +167,15 @@ export class Lockfile { continue; } - // Find the max version and range that we may want bump older packages to + // Find the max version that we may want bump older packages to const maxVersion = Array.from(acceptedVersions).sort(semver.rcompare)[0]; - const maxEntry = entries.find(e => semver.satisfies(maxVersion, e.range)); + // Find all existing ranges that satisfy the new max version, and pick the one that + // results in the highest minimum allowed version, usually being the more specific one + const maxEntry = entries + .filter(e => semver.satisfies(maxVersion, e.range)) + .map(e => ({ e, min: semver.minVersion(e.range) })) + .filter(p => p.min) + .sort((a, b) => semver.rcompare(a.min!, b.min!))[0]?.e; if (!maxEntry) { throw new Error( `No entry found that satisfies max version '${maxVersion}'`, From 46a20ad22fbfd2f5cf3965e8eedbf3a2118b6f35 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 15:58:28 +0100 Subject: [PATCH 03/11] cli: add versions:lint command --- packages/cli/src/commands/index.ts | 6 ++ packages/cli/src/commands/versions/lint.ts | 114 +++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 packages/cli/src/commands/versions/lint.ts diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 1ebe518cac..3f3e8bfe83 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -154,6 +154,12 @@ export function registerCommands(program: CommanderStatic) { ) .action(lazy(() => import('./config/validate').then(m => m.default))); + program + .command('versions:lint') + .option('--fix', 'Fix any auto-fixable versioning problems') + .description('Lint Backstage package versioning') + .action(lazy(() => import('./versions/lint').then(m => m.default))); + program .command('prepack') .description('Prepares a package for packaging before publishing') diff --git a/packages/cli/src/commands/versions/lint.ts b/packages/cli/src/commands/versions/lint.ts new file mode 100644 index 0000000000..2f200a75a9 --- /dev/null +++ b/packages/cli/src/commands/versions/lint.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import { Command } from 'commander'; +import { Lockfile } from '../../lib/versioning'; +import { paths } from '../../lib/paths'; +import partition from 'lodash/partition'; + +// Packages that we try to avoid duplicates for +const INCLUDED = [/^@backstage\//]; + +// Packages that are not allowed to have any duplicates +const FORBID_DUPLICATES = [ + /^@backstage\/core$/, + /^@backstage\/core-api$/, + /^@backstage\/plugin-/, +]; + +export default async (cmd: Command) => { + const fix = Boolean(cmd.fix); + + let success = true; + + const lockfilePath = paths.resolveTargetRoot('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); + const result = lockfile.analyze({ + filter: name => INCLUDED.some(pattern => pattern.test(name)), + }); + + logArray( + result.invalidRanges, + "The following packages versions are invalid and can't be analyzed:", + e => ` ${e.name} @ ${e.range}`, + ); + + if (fix) { + lockfile.replaceVersions(result.newVersions); + + await fs.writeFile(lockfilePath, lockfile.toString(), 'utf8'); + } else { + const [ + newVersionsForbidden, + newVersionsAllowed, + ] = partition(result.newVersions, ({ name }) => + FORBID_DUPLICATES.some(pattern => pattern.test(name)), + ); + if (newVersionsForbidden.length && !fix) { + success = false; + } + + logArray( + newVersionsForbidden, + 'The following packages must be deduplicated, this can be done automatically with --fix', + e => + ` ${e.name} @ ${e.range} bumped from ${e.oldVersion} to ${e.newVersion}`, + ); + logArray( + newVersionsAllowed, + 'The following packages can be deduplicated, this can be done automatically with --fix', + e => + ` ${e.name} @ ${e.range} bumped from ${e.oldVersion} to ${e.newVersion}`, + ); + } + + const [newRangesForbidden, newRangesAllowed] = partition( + result.newRanges, + ({ name }) => FORBID_DUPLICATES.some(pattern => pattern.test(name)), + ); + if (newRangesForbidden.length) { + success = false; + } + + logArray( + newRangesForbidden, + 'The following packages must be deduplicated by updating dependencies in package.json', + e => ` ${e.name} @ ${e.oldRange} should be changed to ${e.newRange}`, + ); + logArray( + newRangesAllowed, + 'The following packages can be deduplicated by updating dependencies in package.json', + e => ` ${e.name} @ ${e.oldRange} should be changed to ${e.newRange}`, + ); + + if (!success) { + throw new Error('Failed versioning check'); + } +}; + +function logArray(arr: T[], header: string, each: (item: T) => void) { + if (arr.length === 0) { + return; + } + + console.log(header); + console.log(); + for (const e of arr) { + console.log(each(e)); + } + console.log(); +} From 06432a0e9973be53a73bd833e1b42eddb1901b5f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 17:17:44 +0100 Subject: [PATCH 04/11] cli: add versions:bump command --- packages/cli/src/commands/index.ts | 5 + packages/cli/src/commands/versions/bump.ts | 199 +++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 packages/cli/src/commands/versions/bump.ts diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 3f3e8bfe83..9f967a2727 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -154,6 +154,11 @@ export function registerCommands(program: CommanderStatic) { ) .action(lazy(() => import('./config/validate').then(m => m.default))); + program + .command('versions:bump') + .description('Bump Backstage packages to the latest versions') + .action(lazy(() => import('./versions/bump').then(m => m.default))); + program .command('versions:lint') .option('--fix', 'Fix any auto-fixable versioning problems') diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts new file mode 100644 index 0000000000..87f6d6c12b --- /dev/null +++ b/packages/cli/src/commands/versions/bump.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import semver from 'semver'; +import { resolve as resolvePath } from 'path'; +import { run, runPlain } from '../../lib/run'; +import { paths } from '../../lib/paths'; +import { Lockfile } from '../../lib/versioning'; + +const PREFIX = '@backstage'; + +const DEP_TYPES = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + +// Package data as returned by `yarn info` +type YarnInfoInspectData = { + name: string; + 'dist-tags': { latest: string }; + versions: string[]; + time: { [version: string]: string }; +}; + +// Possible `yarn info` output +type YarnInfo = { + type: 'inspect'; + data: YarnInfoInspectData | { type: string; data: unknown }; +}; + +type PkgVersionInfo = { + range: string; + name: string; + location: string; +}; + +export default async () => { + const LernaProject = require('@lerna/project'); + const project = new LernaProject(paths.targetDir); + const packages = await project.getPackages(); + + // First we discover all Backstage dependencies within our own repo + const dependencyMap = new Map(); + for (const pkg of packages) { + const deps = DEP_TYPES.flatMap( + t => Object.entries(pkg.get(t) ?? {}) as [string, string][], + ); + + for (const [name, range] of deps) { + if (name.startsWith(PREFIX)) { + dependencyMap.set( + name, + (dependencyMap.get(name) ?? []).concat({ + range, + name: pkg.name, + location: pkg.location, + }), + ); + } + } + } + + // Next check with the package registry what the latest version of all of those dependencies are + const targetVersions = new Map(); + await workerThreads(16, dependencyMap.keys(), async name => { + console.log(`Checking for updates of ${name}`); + const output = await runPlain('yarn', 'info', '--json', name); + const info = JSON.parse(output) as YarnInfo; + if (info.type !== 'inspect') { + throw new Error(`Received unknown yarn info for ${name}, ${output}`); + } + + const data = info.data as YarnInfoInspectData; + const latest = data['dist-tags'].latest; + if (!latest) { + throw new Error(`No latest version found for ${name}`); + } + + targetVersions.set(name, latest); + }); + + // Then figure out which local packages need to have their dependencies bumped + const versionBumps = new Map(); + for (const [name, pkgs] of dependencyMap) { + const targetVersion = targetVersions.get(name)!; + for (const pkg of pkgs) { + if (semver.satisfies(targetVersion, pkg.range)) { + continue; + } + + versionBumps.set( + pkg.name, + (versionBumps.get(pkg.name) ?? []).concat({ + name, + location: pkg.location, + range: `^${targetVersion}`, // TODO(Rugvip): Option to use something else than ^? + }), + ); + } + } + + console.log(); + + // Write all discovered version bumps to package.json in this repo + if (versionBumps.size === 0) { + console.log('All Backstage packages are up to date!'); + } else { + console.log('Some packages are outdated, updating'); + console.log(); + + await workerThreads(16, versionBumps.entries(), async ([name, deps]) => { + const pkgPath = resolvePath(deps[0].location, 'package.json'); + const pkgJson = await fs.readJson(pkgPath); + + for (const dep of deps) { + console.log(`Bumping ${dep.name} in ${name} to ${dep.range}`); + + for (const depType of DEP_TYPES) { + if (depType in pkgJson && dep.name in pkgJson[depType]) { + pkgJson[depType][dep.name] = dep.range; + } + } + } + + await fs.writeJson(pkgPath, pkgJson, { spaces: 2 }); + }); + + console.log(); + console.log("Running 'yarn install' to install new versions"); + console.log(); + await run('yarn', ['install']); + } + + console.log(); + + // Finally we make sure the new lockfile doesn't have any duplicates + const lockfilePath = paths.resolveTargetRoot('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); + const result = lockfile.analyze({ + filter: name => dependencyMap.has(name), + }); + + if (result.newVersions.length > 0) { + console.log(); + console.log('Removing duplicate dependencies from yarn.lock'); + lockfile.replaceVersions(result.newVersions); + await fs.writeFile(lockfilePath, lockfile.toString(), 'utf8'); + + console.log( + "Running 'yarn install' to remove duplicates from node_modules", + ); + console.log(); + await run('yarn', ['install']); + + console.log(); + } + + if (result.newRanges.length > 0) { + throw new Error( + `Version bump failed for ${result.newRanges.map(i => i.name).join(', ')}`, + ); + } +}; + +async function workerThreads( + count: number, + items: IterableIterator, + fn: (item: T) => Promise, +) { + const queue = Array.from(items); + + async function pop() { + const item = queue.pop()!; + if (!item) { + return; + } + + await fn(item); + await pop(); + } + + return Promise.all(Array(count).fill(0).map(pop)); +} From b90ad40d1c6c4e3e235fca9c1b5a652eedc625b8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 17:42:52 +0100 Subject: [PATCH 05/11] cli: add Lockfile.save --- packages/cli/src/commands/versions/bump.ts | 11 +++++++---- packages/cli/src/commands/versions/lint.ts | 7 ++----- packages/cli/src/lib/versioning/Lockfile.test.ts | 7 ++++++- packages/cli/src/lib/versioning/Lockfile.ts | 9 +++++++-- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 87f6d6c12b..c2db4eff6e 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -150,8 +150,7 @@ export default async () => { console.log(); // Finally we make sure the new lockfile doesn't have any duplicates - const lockfilePath = paths.resolveTargetRoot('yarn.lock'); - const lockfile = await Lockfile.load(lockfilePath); + const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); const result = lockfile.analyze({ filter: name => dependencyMap.has(name), }); @@ -160,7 +159,7 @@ export default async () => { console.log(); console.log('Removing duplicate dependencies from yarn.lock'); lockfile.replaceVersions(result.newVersions); - await fs.writeFile(lockfilePath, lockfile.toString(), 'utf8'); + await lockfile.save(); console.log( "Running 'yarn install' to remove duplicates from node_modules", @@ -195,5 +194,9 @@ async function workerThreads( await pop(); } - return Promise.all(Array(count).fill(0).map(pop)); + return Promise.all( + Array(count) + .fill(0) + .map(pop), + ); } diff --git a/packages/cli/src/commands/versions/lint.ts b/packages/cli/src/commands/versions/lint.ts index 2f200a75a9..f91d2b74eb 100644 --- a/packages/cli/src/commands/versions/lint.ts +++ b/packages/cli/src/commands/versions/lint.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import fs from 'fs-extra'; import { Command } from 'commander'; import { Lockfile } from '../../lib/versioning'; import { paths } from '../../lib/paths'; @@ -35,8 +34,7 @@ export default async (cmd: Command) => { let success = true; - const lockfilePath = paths.resolveTargetRoot('yarn.lock'); - const lockfile = await Lockfile.load(lockfilePath); + const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); const result = lockfile.analyze({ filter: name => INCLUDED.some(pattern => pattern.test(name)), }); @@ -49,8 +47,7 @@ export default async (cmd: Command) => { if (fix) { lockfile.replaceVersions(result.newVersions); - - await fs.writeFile(lockfilePath, lockfile.toString(), 'utf8'); + await lockfile.save(); } else { const [ newVersionsForbidden, diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts index 20847242d3..89126c7ce1 100644 --- a/packages/cli/src/lib/versioning/Lockfile.test.ts +++ b/packages/cli/src/lib/versioning/Lockfile.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import fs from 'fs-extra'; import mockFs from 'mock-fs'; import { Lockfile } from './Lockfile'; @@ -84,7 +85,7 @@ describe('Lockfile', () => { expect(lockfile.toString()).toBe(mockA); }); - it('should deduplicate mockA', async () => { + it('should deduplicate and save mockA', async () => { mockFs({ '/yarn.lock': mockA, }); @@ -107,6 +108,10 @@ describe('Lockfile', () => { expect(lockfile.toString()).toBe(mockA); lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockADedup); + + await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockA); + await expect(lockfile.save()).resolves.toBeUndefined(); + await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockADedup); }); it('should deduplicate mockB', async () => { diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts index 8acb387704..59a734c314 100644 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -91,10 +91,11 @@ export class Lockfile { queries.push({ range, version: value.version }); } - return new Lockfile(packages, data); + return new Lockfile(path, packages, data); } - constructor( + private constructor( + private readonly path: string, private readonly packages: Map, private readonly data: LockfileData, ) {} @@ -242,6 +243,10 @@ export class Lockfile { } } + async save() { + await fs.writeFile(this.path, this.toString(), 'utf8'); + } + toString() { return stringifyLockfile(this.data); } From 99134596986b1e5c7b1594a2c50b60d8a13d3995 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 17:44:25 +0100 Subject: [PATCH 06/11] changesets: add cli versions command change --- .changeset/poor-mails-marry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/poor-mails-marry.md diff --git a/.changeset/poor-mails-marry.md b/.changeset/poor-mails-marry.md new file mode 100644 index 0000000000..586bf91c47 --- /dev/null +++ b/.changeset/poor-mails-marry.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add new `versions:lint` and `versions:bump` commands to simplify version management and avoid conflicts From c0f7591dd7ab805ef16e1be727636acbcd7110fd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 18:17:55 +0100 Subject: [PATCH 07/11] cli: refactor out parts of versioning:bump + add tests --- packages/cli/src/commands/versions/bump.ts | 58 ++-------- packages/cli/src/lib/versioning/index.ts | 1 + .../cli/src/lib/versioning/packages.test.ts | 109 ++++++++++++++++++ packages/cli/src/lib/versioning/packages.ts | 90 +++++++++++++++ 4 files changed, 209 insertions(+), 49 deletions(-) create mode 100644 packages/cli/src/lib/versioning/packages.test.ts create mode 100644 packages/cli/src/lib/versioning/packages.ts diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index c2db4eff6e..1d8b9cecf3 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -17,11 +17,13 @@ import fs from 'fs-extra'; import semver from 'semver'; import { resolve as resolvePath } from 'path'; -import { run, runPlain } from '../../lib/run'; +import { run } from '../../lib/run'; import { paths } from '../../lib/paths'; -import { Lockfile } from '../../lib/versioning'; - -const PREFIX = '@backstage'; +import { + mapDependencies, + fetchPackageInfo, + Lockfile, +} from '../../lib/versioning'; const DEP_TYPES = [ 'dependencies', @@ -30,20 +32,6 @@ const DEP_TYPES = [ 'optionalDependencies', ]; -// Package data as returned by `yarn info` -type YarnInfoInspectData = { - name: string; - 'dist-tags': { latest: string }; - versions: string[]; - time: { [version: string]: string }; -}; - -// Possible `yarn info` output -type YarnInfo = { - type: 'inspect'; - data: YarnInfoInspectData | { type: string; data: unknown }; -}; - type PkgVersionInfo = { range: string; name: string; @@ -51,43 +39,15 @@ type PkgVersionInfo = { }; export default async () => { - const LernaProject = require('@lerna/project'); - const project = new LernaProject(paths.targetDir); - const packages = await project.getPackages(); - // First we discover all Backstage dependencies within our own repo - const dependencyMap = new Map(); - for (const pkg of packages) { - const deps = DEP_TYPES.flatMap( - t => Object.entries(pkg.get(t) ?? {}) as [string, string][], - ); - - for (const [name, range] of deps) { - if (name.startsWith(PREFIX)) { - dependencyMap.set( - name, - (dependencyMap.get(name) ?? []).concat({ - range, - name: pkg.name, - location: pkg.location, - }), - ); - } - } - } + const dependencyMap = await mapDependencies(); // Next check with the package registry what the latest version of all of those dependencies are const targetVersions = new Map(); await workerThreads(16, dependencyMap.keys(), async name => { console.log(`Checking for updates of ${name}`); - const output = await runPlain('yarn', 'info', '--json', name); - const info = JSON.parse(output) as YarnInfo; - if (info.type !== 'inspect') { - throw new Error(`Received unknown yarn info for ${name}, ${output}`); - } - - const data = info.data as YarnInfoInspectData; - const latest = data['dist-tags'].latest; + const info = await fetchPackageInfo(name); + const latest = info['dist-tags'].latest; if (!latest) { throw new Error(`No latest version found for ${name}`); } diff --git a/packages/cli/src/lib/versioning/index.ts b/packages/cli/src/lib/versioning/index.ts index 6f0a649ee4..71fb7647ce 100644 --- a/packages/cli/src/lib/versioning/index.ts +++ b/packages/cli/src/lib/versioning/index.ts @@ -15,3 +15,4 @@ */ export { Lockfile } from './Lockfile'; +export { fetchPackageInfo, mapDependencies } from './packages'; diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts new file mode 100644 index 0000000000..ba328fa356 --- /dev/null +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import mockFs from 'mock-fs'; +import * as runObj from '../run'; +import { paths } from '../paths'; +import { fetchPackageInfo, mapDependencies } from './packages'; + +describe('fetchPackageInfo', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should forward info', async () => { + jest + .spyOn(runObj, 'runPlain') + .mockResolvedValue(`{"type":"inspect","data":{"the":"data"}}`); + + await expect(fetchPackageInfo('my-package')).resolves.toEqual({ + the: 'data', + }); + expect(runObj.runPlain).toHaveBeenCalledWith( + 'yarn', + 'info', + '--json', + 'my-package', + ); + }); +}); + +describe('mapDependencies', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + it('should derp', async () => { + // Make sure all modules involved in package discovery are in the module cache before we mock fs + const LernaProject = require('@lerna/project'); + const project = new LernaProject(paths.targetDir); + await project.getPackages(); + + mockFs({ + '/lerna.json': JSON.stringify({ + packages: ['pkgs/*'], + }), + '/pkgs/a/package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '1 || 2', + }, + }), + '/pkgs/b/package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '3', + '@backstage/cli': '^0', + }, + }), + }); + + const oldDir = paths.targetDir; + paths.targetDir = '/'; + + const dependencyMap = await mapDependencies(); + expect(Array.from(dependencyMap)).toEqual([ + [ + '@backstage/core', + [ + { + name: 'a', + range: '1 || 2', + location: '/pkgs/a', + }, + { + name: 'b', + range: '3', + location: '/pkgs/b', + }, + ], + ], + [ + '@backstage/cli', + [ + { + name: 'b', + range: '^0', + location: '/pkgs/b', + }, + ], + ], + ]); + + paths.targetDir = oldDir; + }); +}); diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts new file mode 100644 index 0000000000..239fe575d6 --- /dev/null +++ b/packages/cli/src/lib/versioning/packages.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { runPlain } from '../../lib/run'; +import { paths } from '../../lib/paths'; + +const PREFIX = '@backstage'; + +const DEP_TYPES = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +]; + +// Package data as returned by `yarn info` +type YarnInfoInspectData = { + name: string; + 'dist-tags': { latest: string }; + versions: string[]; + time: { [version: string]: string }; +}; + +// Possible `yarn info` output +type YarnInfo = { + type: 'inspect'; + data: YarnInfoInspectData | { type: string; data: unknown }; +}; + +type PkgVersionInfo = { + range: string; + name: string; + location: string; +}; + +export async function fetchPackageInfo( + name: string, +): Promise { + const output = await runPlain('yarn', 'info', '--json', name); + const info = JSON.parse(output) as YarnInfo; + if (info.type !== 'inspect') { + throw new Error(`Received unknown yarn info for ${name}, ${output}`); + } + + return info.data as YarnInfoInspectData; +} + +/** Map all dependencies in the repo as dependency => dependents */ +export async function mapDependencies(): Promise< + Map +> { + const LernaProject = require('@lerna/project'); + const project = new LernaProject(paths.targetDir); + const packages = await project.getPackages(); + + const dependencyMap = new Map(); + for (const pkg of packages) { + const deps = DEP_TYPES.flatMap( + t => Object.entries(pkg.get(t) ?? {}) as [string, string][], + ); + + for (const [name, range] of deps) { + if (name.startsWith(PREFIX)) { + dependencyMap.set( + name, + (dependencyMap.get(name) ?? []).concat({ + range, + name: pkg.name, + location: pkg.location, + }), + ); + } + } + } + + return dependencyMap; +} From 1af14032a090d599e6e9fc438350eb18528a21f1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 18:32:32 +0100 Subject: [PATCH 08/11] cli: rename versions:lint to versions:check --- .changeset/poor-mails-marry.md | 2 +- packages/cli/src/commands/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/poor-mails-marry.md b/.changeset/poor-mails-marry.md index 586bf91c47..fdc7e6f1b8 100644 --- a/.changeset/poor-mails-marry.md +++ b/.changeset/poor-mails-marry.md @@ -2,4 +2,4 @@ '@backstage/cli': patch --- -Add new `versions:lint` and `versions:bump` commands to simplify version management and avoid conflicts +Add new `versions:check` and `versions:bump` commands to simplify version management and avoid conflicts diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 9f967a2727..60c1b45cfc 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -160,9 +160,9 @@ export function registerCommands(program: CommanderStatic) { .action(lazy(() => import('./versions/bump').then(m => m.default))); program - .command('versions:lint') + .command('versions:check') .option('--fix', 'Fix any auto-fixable versioning problems') - .description('Lint Backstage package versioning') + .description('Check Backstage package versioning') .action(lazy(() => import('./versions/lint').then(m => m.default))); program From 351539680bcf0eb1406591a0e81590cd89eb8114 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 20:37:06 +0100 Subject: [PATCH 09/11] cli: review feedback on versions:* commands --- packages/cli/src/commands/versions/bump.ts | 2 +- packages/cli/src/commands/versions/lint.ts | 2 +- packages/cli/src/lib/versioning/Lockfile.ts | 10 +++++----- packages/cli/src/lib/versioning/packages.test.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 1d8b9cecf3..526ab9d4cf 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -145,7 +145,7 @@ async function workerThreads( const queue = Array.from(items); async function pop() { - const item = queue.pop()!; + const item = queue.pop(); if (!item) { return; } diff --git a/packages/cli/src/commands/versions/lint.ts b/packages/cli/src/commands/versions/lint.ts index f91d2b74eb..e2c36ba094 100644 --- a/packages/cli/src/commands/versions/lint.ts +++ b/packages/cli/src/commands/versions/lint.ts @@ -97,7 +97,7 @@ export default async (cmd: Command) => { } }; -function logArray(arr: T[], header: string, each: (item: T) => void) { +function logArray(arr: T[], header: string, each: (item: T) => string) { if (arr.length === 0) { return; } diff --git a/packages/cli/src/lib/versioning/Lockfile.ts b/packages/cli/src/lib/versioning/Lockfile.ts index 59a734c314..893beca6e2 100644 --- a/packages/cli/src/lib/versioning/Lockfile.ts +++ b/packages/cli/src/lib/versioning/Lockfile.ts @@ -51,7 +51,7 @@ type AnalyzeResultNewVersion = { newVersion: string; }; -/** Entries that {would need a dependency update in package.json to be deduplicated */ +/** Entries that would need a dependency update in package.json to be deduplicated */ type AnalyzeResultNewRange = { name: string; oldRange: string; @@ -118,13 +118,13 @@ export class Lockfile { continue; } - // Get rid of an signal any invalid ranges upfront + // Get rid of and signal any invalid ranges upfront const invalid = allEntries.filter(e => !semver.validRange(e.range)); result.invalidRanges.push( ...invalid.map(({ range }) => ({ name, range })), ); - // Grab all valid entries, if there isn't at least 2 different valid ones we're done + // Grab all valid entries, if there aren't at least 2 different valid ones we're done const entries = allEntries.filter(e => semver.validRange(e.range)); if (entries.length < 2) { continue; @@ -163,7 +163,7 @@ export class Lockfile { acceptedVersions.add(acceptedVersion); } - // If all ranges where able to accept the same version, we're done + // If all ranges were able to accept the same version, we're done if (acceptedVersions.size === 1) { continue; } @@ -229,7 +229,7 @@ export class Lockfile { } this.data[query] = matchingEntry[1]; - // Update out internal data structure + // Update our internal data structure const entry = this.packages.get(name)?.find(e => e.range === range); if (!entry) { throw new Error(`No entry data for ${query}`); diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index ba328fa356..fd37d7af95 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -47,7 +47,7 @@ describe('mapDependencies', () => { jest.resetAllMocks(); }); - it('should derp', async () => { + it('should read dependencies', async () => { // Make sure all modules involved in package discovery are in the module cache before we mock fs const LernaProject = require('@lerna/project'); const project = new LernaProject(paths.targetDir); From 0c597a8d62b448a152c951bdcee988a84657cba1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 22 Nov 2020 20:18:19 +0100 Subject: [PATCH 10/11] cli: work around flaky formatting in bump.ts --- packages/cli/src/commands/versions/bump.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 526ab9d4cf..eec8abed9b 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -157,6 +157,6 @@ async function workerThreads( return Promise.all( Array(count) .fill(0) - .map(pop), + .map(() => pop()), ); } From d20f605082f3f2c1a0812c9d8172aafb298d6fd1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 23 Nov 2020 12:43:54 +0100 Subject: [PATCH 11/11] cli: add test for versions:bump command --- .../cli/src/commands/versions/bump.test.ts | 161 ++++++++++++++++++ packages/cli/src/commands/versions/bump.ts | 2 +- .../cli/src/lib/versioning/packages.test.ts | 2 +- packages/cli/src/lib/versioning/packages.ts | 9 +- 4 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/commands/versions/bump.test.ts diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts new file mode 100644 index 0000000000..687708b074 --- /dev/null +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { resolve as resolvePath } from 'path'; +import { paths } from '../../lib/paths'; +import { mapDependencies } from '../../lib/versioning'; +import * as runObj from '../../lib/run'; +import bump from './bump'; +import { withLogCollector } from '@backstage/test-utils'; + +const REGISTRY_VERSIONS: { [name: string]: string } = { + '@backstage/core': '1.0.7', + '@backstage/theme': '2.0.0', +}; + +const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +`; + +const lockfileMock = `${HEADER} +"@backstage/core@^1.0.5": + version "1.0.6" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + +"@backstage/core@^1.0.3": + version "1.0.3" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + +"@backstage/theme@^1.0.0": + version "1.0.0" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz +`; + +const lockfileMockResult = `${HEADER} +"@backstage/core@^1.0.3", "@backstage/core@^1.0.5": + version "1.0.6" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz + +"@backstage/theme@^1.0.0": + version "1.0.0" + resolved "https://my-registry/a-1.0.01.tgz#abc123" + integrity sha512-xyz +`; + +describe('bump', () => { + afterEach(() => { + mockFs.restore(); + jest.resetAllMocks(); + }); + + 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); + + mockFs({ + '/yarn.lock': lockfileMock, + '/lerna.json': JSON.stringify({ + packages: ['packages/*'], + }), + '/packages/a/package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), + '/packages/b/package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), + }); + + paths.targetDir = '/'; + jest + .spyOn(paths, 'resolveTargetRoot') + .mockImplementation((...paths) => resolvePath('/', ...paths)); + 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(); + }); + expect(logs.filter(Boolean)).toEqual([ + 'Checking for updates of @backstage/theme', + 'Checking for updates of @backstage/core', + 'Some packages are outdated, updating', + 'Bumping @backstage/theme in b to ^2.0.0', + "Running 'yarn install' to install new versions", + 'Removing duplicate dependencies from yarn.lock', + "Running 'yarn install' to remove duplicates from node_modules", + ]); + + expect(runObj.runPlain).toHaveBeenCalledTimes(2); + expect(runObj.runPlain).toHaveBeenCalledWith( + 'yarn', + 'info', + '--json', + '@backstage/core', + ); + expect(runObj.runPlain).toHaveBeenCalledWith( + 'yarn', + 'info', + '--json', + '@backstage/theme', + ); + + expect(runObj.run).toHaveBeenCalledTimes(2); + expect(runObj.run).toHaveBeenCalledWith('yarn', ['install']); + + const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + expect(lockfileContents).toBe(lockfileMockResult); + + const packageA = await fs.readJson('/packages/a/package.json'); + expect(packageA).toEqual({ + name: 'a', + dependencies: { + '@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/core': '^1.0.3', // not bumped + '@backstage/theme': '^2.0.0', // bumped since newer + }, + }); + }); +}); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index eec8abed9b..00944c5db9 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -40,7 +40,7 @@ type PkgVersionInfo = { export default async () => { // First we discover all Backstage dependencies within our own repo - const dependencyMap = await mapDependencies(); + const dependencyMap = await mapDependencies(paths.targetDir); // Next check with the package registry what the latest version of all of those dependencies are const targetVersions = new Map(); diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index fd37d7af95..8155d4b5c0 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -75,7 +75,7 @@ describe('mapDependencies', () => { const oldDir = paths.targetDir; paths.targetDir = '/'; - const dependencyMap = await mapDependencies(); + const dependencyMap = await mapDependencies(paths.targetDir); 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 239fe575d6..76e5dc49b0 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -15,7 +15,6 @@ */ import { runPlain } from '../../lib/run'; -import { paths } from '../../lib/paths'; const PREFIX = '@backstage'; @@ -59,11 +58,11 @@ export async function fetchPackageInfo( } /** Map all dependencies in the repo as dependency => dependents */ -export async function mapDependencies(): Promise< - Map -> { +export async function mapDependencies( + targetDir: string, +): Promise> { const LernaProject = require('@lerna/project'); - const project = new LernaProject(paths.targetDir); + const project = new LernaProject(targetDir); const packages = await project.getPackages(); const dependencyMap = new Map();