Merge pull request #9092 from backstage/mob/versions-bump2

cli: Add versions:bump params for bumping to specific releases
This commit is contained in:
Johan Haals
2022-02-09 09:35:01 +01:00
committed by GitHub
16 changed files with 787 additions and 63 deletions
+3 -1
View File
@@ -32,6 +32,7 @@
"@backstage/config": "^0.1.13",
"@backstage/config-loader": "^0.9.3",
"@backstage/errors": "^0.2.0",
"@backstage/release-manifests": "^0.0.0",
"@backstage/types": "^0.1.1",
"@hot-loader/react-dom": "^16.13.0",
"@manypkg/get-packages": "^1.1.3",
@@ -143,7 +144,8 @@
"del": "^6.0.0",
"mock-fs": "^5.1.0",
"nodemon": "^2.0.2",
"ts-node": "^10.0.0"
"ts-node": "^10.0.0",
"msw": "^0.35.0"
},
"peerDependencies": {
"@microsoft/api-extractor": "^7.19.2"
+5
View File
@@ -341,6 +341,11 @@ export function registerCommands(program: CommanderStatic) {
'--pattern <glob>',
'Override glob for matching packages to upgrade',
)
.option(
'--release <version|next|main>',
'Bump to a specific Backstage release line or version',
'main',
)
.description('Bump Backstage packages to the latest versions')
.action(lazy(() => import('./versions/bump').then(m => m.default)));
+371 -43
View File
@@ -20,8 +20,14 @@ import { Command } from 'commander';
import { resolve as resolvePath } from 'path';
import { paths } from '../../lib/paths';
import * as runObj from '../../lib/run';
import bump, { bumpBackstageJsonVersion } from './bump';
import { withLogCollector } from '@backstage/test-utils';
import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump';
import {
setupRequestMockHandlers,
withLogCollector,
} from '@backstage/test-utils';
import { YarnInfoInspectData } from '../../lib/versioning/packages';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
// Remove log coloring to simplify log matching
jest.mock('chalk', () => ({
@@ -38,6 +44,7 @@ const REGISTRY_VERSIONS: { [name: string]: string } = {
'@backstage/theme': '2.0.0',
'@backstage-extra/custom': '1.1.0',
'@backstage-extra/custom-two': '2.0.0',
'@backstage/create-app': '1.0.0',
};
const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
@@ -82,6 +89,8 @@ describe('bump', () => {
mockFs.restore();
jest.resetAllMocks();
});
const worker = setupServer();
setupRequestMockHandlers(worker);
it('should bump backstage dependencies', async () => {
mockFs({
@@ -121,9 +130,20 @@ describe('bump', () => {
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
'https://versions.backstage.io/v1/tags/main/manifest.json',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
packages: [],
}),
),
),
);
const { log: logs } = await withLogCollector(['log'], async () => {
await bump({ pattern: null } as unknown as Command);
await bump({ pattern: null, release: 'main' } as unknown as Command);
});
expect(logs.filter(Boolean)).toEqual([
'Using default pattern glob @backstage/*',
@@ -142,7 +162,7 @@ describe('bump', () => {
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(4);
expect(runObj.runPlain).toHaveBeenCalledTimes(3);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
@@ -179,6 +199,225 @@ describe('bump', () => {
});
});
it('should prefer dependency versions from release manifest', async () => {
mockFs({
'/yarn.lock': lockfileMock,
'/package.json': JSON.stringify({
workspaces: {
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',
},
}),
});
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);
worker.use(
rest.get(
'https://versions.backstage.io/v1/tags/main/manifest.json',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
releaseVersion: '0.0.1',
packages: [
{
name: '@backstage/theme',
version: '5.0.0',
},
{
name: '@backstage/create-app',
version: '3.0.0',
},
],
}),
),
),
);
const { log: logs } = await withLogCollector(['log'], async () => {
await bump({ pattern: null, release: 'main' } as unknown as Command);
});
expect(logs.filter(Boolean)).toEqual([
'Using default pattern glob @backstage/*',
'Checking for updates of @backstage/core',
'Checking for updates of @backstage/theme',
'Checking for updates of @backstage/theme',
'Checking for updates of @backstage/core-api',
'Some packages are outdated, updating',
'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/theme in b to ^5.0.0',
'Creating backstage.json',
'Running yarn install to install new versions',
'⚠️ The following packages may have breaking changes:',
' @backstage/theme : 1.0.0 ~> 5.0.0',
' https://github.com/backstage/backstage/blob/master/packages/theme/CHANGELOG.md',
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(2);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
'--json',
'@backstage/core',
);
expect(runObj.runPlain).not.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).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': '^5.0.0', // bumped since newer
},
});
});
it('should only bump packages in the manifest when a specific release is specified', async () => {
mockFs({
'/yarn.lock': lockfileMock,
'/package.json': JSON.stringify({
workspaces: {
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',
},
}),
});
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name: name,
'dist-tags': {
latest: REGISTRY_VERSIONS[name],
},
},
}),
);
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
'https://versions.backstage.io/v1/releases/1.0.0/manifest.json',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
releaseVersion: '2.0.0',
packages: [
{ name: '@backstage/core', version: '5.0.0' },
{ name: '@backstage/core-api', version: '5.0.0' },
],
}),
),
),
);
const { log: logs } = await withLogCollector(['log'], async () => {
await expect(
bump({ pattern: null, release: '1.0.0' } as unknown as Command),
).rejects.toThrow('Duplicate versions present after package bump');
});
expect(logs.filter(Boolean)).toEqual([
'Using default pattern glob @backstage/*',
'Checking for updates of @backstage/core',
'Checking for updates of @backstage/theme',
'Package info not found, ignoring package @backstage/theme',
'Checking for updates of @backstage/core',
'Checking for updates of @backstage/theme',
'Checking for updates of @backstage/core-api',
'Package info not found, ignoring package @backstage/theme',
'Some packages are outdated, updating',
'bumping @backstage/core in a to ^5.0.0',
'bumping @backstage/core in b to ^5.0.0',
'Creating backstage.json',
'Running yarn install to install new versions',
'⚠️ The following packages may have breaking changes:',
' @backstage/core : 1.0.3 ~> 5.0.0',
' https://github.com/backstage/backstage/blob/master/packages/core/CHANGELOG.md',
'Version bump complete!',
]);
expect(runObj.run).toHaveBeenCalledTimes(1);
expect(runObj.run).toHaveBeenCalledWith('yarn', ['install']);
const packageA = await fs.readJson('/packages/a/package.json');
expect(packageA).toEqual({
name: 'a',
dependencies: {
'@backstage/core': '^5.0.0',
},
});
const packageB = await fs.readJson('/packages/b/package.json');
expect(packageB).toEqual({
name: 'b',
dependencies: {
'@backstage/core': '^5.0.0',
'@backstage/theme': '^1.0.0',
},
});
expect(await fs.readJson('/backstage.json')).toEqual({ version: '2.0.0' });
});
it('should bump backstage dependencies and dependencies matching pattern glob', async () => {
const customLockfileMock = `${lockfileMock}
"@backstage-extra/custom@^1.1.0":
@@ -246,9 +485,23 @@ describe('bump', () => {
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
'https://versions.backstage.io/v1/tags/main/manifest.json',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
packages: [],
}),
),
),
);
const { log: logs } = await withLogCollector(['log'], async () => {
await bump({ pattern: '@{backstage,backstage-extra}/*' } as any);
await bump({
pattern: '@{backstage,backstage-extra}/*',
release: 'main',
} as any);
});
expect(logs.filter(Boolean)).toEqual([
'Using custom pattern glob @{backstage,backstage-extra}/*',
@@ -265,6 +518,7 @@ describe('bump', () => {
'bumping @backstage-extra/custom-two in a to ^2.0.0',
'bumping @backstage-extra/custom-two in b to ^2.0.0',
'bumping @backstage/theme in b to ^2.0.0',
'Skipping backstage.json update as custom pattern is used',
'Running yarn install to install new versions',
'⚠️ The following packages may have breaking changes:',
' @backstage-extra/custom-two : 1.0.0 ~> 2.0.0',
@@ -273,7 +527,7 @@ describe('bump', () => {
'Version bump complete!',
]);
expect(runObj.runPlain).toHaveBeenCalledTimes(6);
expect(runObj.runPlain).toHaveBeenCalledTimes(5);
expect(runObj.runPlain).toHaveBeenCalledWith(
'yarn',
'info',
@@ -342,9 +596,20 @@ describe('bump', () => {
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async () => '');
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
worker.use(
rest.get(
'https://versions.backstage.io/v1/tags/main/manifest.json',
(_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
packages: [],
}),
),
),
);
const { log: logs } = await withLogCollector(['log'], async () => {
await bump({ pattern: null } as unknown as Command);
await bump({ pattern: null, release: 'main' } as unknown as Command);
});
expect(logs.filter(Boolean)).toEqual([
'Using default pattern glob @backstage/*',
@@ -393,27 +658,12 @@ describe('bumpBackstageJsonVersion', () => {
'/backstage.json': JSON.stringify({ version: '0.0.1' }),
});
paths.targetDir = '/';
const latest = '1.4.1';
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name,
'dist-tags': {
latest,
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
await bumpBackstageJsonVersion();
const json = await fs.readJson('/backstage.json');
expect(json).toEqual({ version: '1.4.1' });
await bumpBackstageJsonVersion('1.4.1');
expect(await fs.readJson('/backstage.json')).toEqual({ version: '1.4.1' });
});
it("should create backstage.json if doesn't exist", async () => {
@@ -423,22 +673,100 @@ describe('bumpBackstageJsonVersion', () => {
jest
.spyOn(paths, 'resolveTargetRoot')
.mockImplementation((...path) => resolvePath('/', ...path));
jest.spyOn(runObj, 'runPlain').mockImplementation(async (...[, , , name]) =>
JSON.stringify({
type: 'inspect',
data: {
name,
'dist-tags': {
latest,
},
},
}),
);
jest.spyOn(runObj, 'run').mockResolvedValue(undefined);
await bumpBackstageJsonVersion();
const json = await fs.readJson('/backstage.json');
expect(json).toEqual({ version: '1.4.1' });
await bumpBackstageJsonVersion(latest);
expect(await fs.readJson('/backstage.json')).toEqual({ version: latest });
});
});
describe('createVersionFinder', () => {
async function findVersion(tag: string, data: Partial<YarnInfoInspectData>) {
const fetcher = () =>
Promise.resolve({
name: '@backstage/core',
'dist-tags': {},
versions: [],
time: {},
...data,
});
const versionFinder = createVersionFinder({
releaseLine: tag,
packageInfoFetcher: fetcher,
});
let result;
await withLogCollector(async () => {
result = await versionFinder('@backstage/core');
});
return result;
}
it('should create version finder', async () => {
await expect(
findVersion('latest', {
time: { '1.0.0': '2020-01-01T00:00:00.000Z' },
'dist-tags': { latest: '1.0.0' },
}),
).resolves.toBe('1.0.0');
await expect(
findVersion('main', {
time: { '1.0.0': '2020-01-01T00:00:00.000Z' },
'dist-tags': { latest: '1.0.0' },
}),
).resolves.toBe('1.0.0');
await expect(
findVersion('next', {
time: { '1.0.0': '2020-01-01T00:00:00.000Z' },
'dist-tags': { latest: '1.0.0' },
}),
).resolves.toBe('1.0.0');
await expect(
findVersion('next', {
time: {
'1.0.0': '2020-01-01T00:00:00.000Z',
'0.9.0': '2010-01-01T00:00:00.000Z',
},
'dist-tags': { latest: '1.0.0', next: '0.9.0' },
}),
).resolves.toBe('1.0.0');
await expect(
findVersion('next', {
time: {
'1.0.0': '2020-01-01T00:00:00.000Z',
'0.9.0': '2020-02-01T00:00:00.000Z',
},
'dist-tags': { latest: '1.0.0', next: '0.9.0' },
}),
).resolves.toBe('0.9.0');
await expect(findVersion('next', {})).rejects.toThrow(
"No target 'latest' version found for @backstage/core",
);
await expect(
findVersion('next', {
time: {
'0.9.0': '2020-02-01T00:00:00.000Z',
},
'dist-tags': { latest: '1.0.0', next: '0.9.0' },
}),
).rejects.toThrow(
"No time available for version '1.0.0' of @backstage/core",
);
await expect(
findVersion('next', {
time: {
'1.0.0': '2020-01-01T00:00:00.000Z',
},
'dist-tags': { latest: '1.0.0', next: '0.9.0' },
}),
).rejects.toThrow(
"No time available for version '0.9.0' of @backstage/core",
);
});
});
+106 -18
View File
@@ -19,7 +19,7 @@ import chalk from 'chalk';
import semver from 'semver';
import minimatch from 'minimatch';
import { Command } from 'commander';
import { isError } from '@backstage/errors';
import { isError, NotFoundError } from '@backstage/errors';
import { resolve as resolvePath } from 'path';
import { run } from '../../lib/run';
import { paths } from '../../lib/paths';
@@ -27,10 +27,16 @@ import {
mapDependencies,
fetchPackageInfo,
Lockfile,
YarnInfoInspectData,
} from '../../lib/versioning';
import { forbiddenDuplicatesFilter } from './lint';
import { BACKSTAGE_JSON } from '@backstage/cli-common';
import { runParallelWorkers } from '../../lib/parallel';
import {
getManifestByReleaseLine,
getManifestByVersion,
ReleaseManifest,
} from '@backstage/release-manifests';
const DEP_TYPES = [
'dependencies',
@@ -60,7 +66,22 @@ export default async (cmd: Command) => {
console.log(`Using custom pattern glob ${pattern}`);
}
const findTargetVersion = createVersionFinder();
let findTargetVersion: (name: string) => Promise<string>;
let releaseManifest: ReleaseManifest;
if (semver.valid(cmd.release)) {
releaseManifest = await getManifestByVersion({ version: cmd.release });
findTargetVersion = createStrictVersionFinder({
releaseManifest,
});
} else {
releaseManifest = await getManifestByReleaseLine({
releaseLine: cmd.release,
});
findTargetVersion = createVersionFinder({
releaseLine: cmd.releaseLine,
releaseManifest,
});
}
// First we discover all Backstage dependencies within our own repo
const dependencyMap = await mapDependencies(paths.targetDir, pattern);
@@ -213,8 +234,16 @@ export default async (cmd: Command) => {
console.log();
await bumpBackstageJsonVersion();
// Do not update backstage.json when upgrade patterns are used.
if (pattern === DEFAULT_PATTERN_GLOB) {
await bumpBackstageJsonVersion(releaseManifest.releaseVersion);
} else {
console.log(
chalk.yellow(
`Skipping backstage.json update as custom pattern is used`,
),
);
}
console.log();
console.log(
`Running ${chalk.blue('yarn install')} to install new versions`,
@@ -284,9 +313,38 @@ export default async (cmd: Command) => {
}
};
function createVersionFinder() {
const found = new Map<string, string>();
export function createStrictVersionFinder(options: {
releaseManifest: ReleaseManifest;
}) {
const releasePackages = new Map(
options.releaseManifest.packages.map(p => [p.name, p.version]),
);
return async function findTargetVersion(name: string) {
console.log(`Checking for updates of ${name}`);
const manifestVersion = releasePackages.get(name);
if (manifestVersion) {
return manifestVersion;
}
throw new NotFoundError(`Package ${name} not found in release manifest`);
};
}
export function createVersionFinder(options: {
releaseLine?: string;
packageInfoFetcher?: () => Promise<YarnInfoInspectData>;
releaseManifest?: ReleaseManifest;
}) {
const {
releaseLine = 'latest',
packageInfoFetcher = fetchPackageInfo,
releaseManifest,
} = options;
// The main release line is just an alias for latest
const distTag = releaseLine === 'main' ? 'latest' : releaseLine;
const found = new Map<string, string>();
const releasePackages = new Map(
releaseManifest?.packages.map(p => [p.name, p.version]),
);
return async function findTargetVersion(name: string) {
const existing = found.get(name);
if (existing) {
@@ -294,17 +352,50 @@ function createVersionFinder() {
}
console.log(`Checking for updates of ${name}`);
const info = await fetchPackageInfo(name);
const latest = info['dist-tags'].latest;
if (!latest) {
throw new Error(`No latest version found for ${name}`);
const manifestVersion = releasePackages.get(name);
if (manifestVersion) {
return manifestVersion;
}
found.set(name, latest);
return latest;
const info = await packageInfoFetcher(name);
const latestVersion = info['dist-tags'].latest;
if (!latestVersion) {
throw new Error(`No target 'latest' version found for ${name}`);
}
const taggedVersion = info['dist-tags'][distTag];
if (distTag === 'latest' || !taggedVersion) {
found.set(name, latestVersion);
return latestVersion;
}
const latestVersionDateStr = info.time[latestVersion];
const taggedVersionDateStr = info.time[taggedVersion];
if (!latestVersionDateStr) {
throw new Error(
`No time available for version '${latestVersion}' of ${name}`,
);
}
if (!taggedVersionDateStr) {
throw new Error(
`No time available for version '${taggedVersion}' of ${name}`,
);
}
const latestVersionRelease = new Date(latestVersionDateStr).getTime();
const taggedVersionRelease = new Date(taggedVersionDateStr).getTime();
if (latestVersionRelease > taggedVersionRelease) {
// Prefer latest version if it's newer.
found.set(name, latestVersion);
return latestVersion;
}
found.set(name, taggedVersion);
return taggedVersion;
};
}
export async function bumpBackstageJsonVersion() {
export async function bumpBackstageJsonVersion(version: string) {
const backstageJsonPath = paths.resolveTargetRoot(BACKSTAGE_JSON);
const backstageJson = await fs.readJSON(backstageJsonPath).catch(e => {
if (e.code === 'ENOENT') {
@@ -314,10 +405,7 @@ export async function bumpBackstageJsonVersion() {
throw e;
});
const info = await fetchPackageInfo('@backstage/create-app');
const { latest } = info['dist-tags'];
if (backstageJson?.version === latest) {
if (backstageJson?.version === version) {
return;
}
@@ -331,7 +419,7 @@ export async function bumpBackstageJsonVersion() {
await fs.writeJson(
backstageJsonPath,
{ ...backstageJson, version: latest },
{ ...backstageJson, version },
{
spaces: 2,
encoding: 'utf8',
+1 -1
View File
@@ -28,7 +28,7 @@ const DEP_TYPES = [
// Package data as returned by `yarn info`
export type YarnInfoInspectData = {
name: string;
'dist-tags': { latest: string };
'dist-tags': Record<string, string>;
versions: string[];
time: { [version: string]: string };
};