diff --git a/.changeset/cold-houses-type.md b/.changeset/cold-houses-type.md new file mode 100644 index 0000000000..05155c96c0 --- /dev/null +++ b/.changeset/cold-houses-type.md @@ -0,0 +1,7 @@ +--- +'@backstage/cli': patch +--- + +Introduces a new `--release` parameter to the `backstage-cli versions:bump` command. +The release can be either a specific version, for example `0.99.1`, or the latest `main` or `next` release. +The default behavior is to bump to the latest `main` release. diff --git a/.changeset/green-peaches-explode.md b/.changeset/green-peaches-explode.md new file mode 100644 index 0000000000..cfebebffc3 --- /dev/null +++ b/.changeset/green-peaches-explode.md @@ -0,0 +1,6 @@ +--- +'@backstage/release-manifests': patch +--- + +Introduces a new package with utilities for fetching release manifests. +This package will primarily be used by the `@backstage/cli` package. diff --git a/packages/cli/package.json b/packages/cli/package.json index 2724870aa9..4f1eccc3a0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 2211af1dda..770d216ba2 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -341,6 +341,11 @@ export function registerCommands(program: CommanderStatic) { '--pattern ', 'Override glob for matching packages to upgrade', ) + .option( + '--release ', + '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))); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index ae1a4c4627..d2d3cf1573 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -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) { + 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", + ); }); }); diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 0d7c1cdfae..fc455b9fbd 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -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; + 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(); +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; + 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(); + 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', diff --git a/packages/cli/src/lib/versioning/packages.ts b/packages/cli/src/lib/versioning/packages.ts index 1dabec6032..9f2f2bdc65 100644 --- a/packages/cli/src/lib/versioning/packages.ts +++ b/packages/cli/src/lib/versioning/packages.ts @@ -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; versions: string[]; time: { [version: string]: string }; }; diff --git a/packages/release-manifests/.eslintrc.js b/packages/release-manifests/.eslintrc.js new file mode 100644 index 0000000000..16a033dbc6 --- /dev/null +++ b/packages/release-manifests/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint.backend')], +}; diff --git a/packages/release-manifests/CHANGELOG.md b/packages/release-manifests/CHANGELOG.md new file mode 100644 index 0000000000..3aac6c8e6a --- /dev/null +++ b/packages/release-manifests/CHANGELOG.md @@ -0,0 +1 @@ +# @backstage/release-manifests diff --git a/packages/release-manifests/README.md b/packages/release-manifests/README.md new file mode 100644 index 0000000000..6111173f15 --- /dev/null +++ b/packages/release-manifests/README.md @@ -0,0 +1,3 @@ +# @backstage/release-manifests + +This package provides a mapping between a Backstage release and the packages included in that release. diff --git a/packages/release-manifests/api-report.md b/packages/release-manifests/api-report.md new file mode 100644 index 0000000000..1ffa35838b --- /dev/null +++ b/packages/release-manifests/api-report.md @@ -0,0 +1,34 @@ +## API Report File for "@backstage/release-manifests" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +// @public +export function getManifestByReleaseLine( + options: GetManifestByReleaseLineOptions, +): Promise; + +// @public +export type GetManifestByReleaseLineOptions = { + releaseLine: string; +}; + +// @public +export function getManifestByVersion( + options: GetManifestByVersionOptions, +): Promise; + +// @public +export type GetManifestByVersionOptions = { + version: string; +}; + +// @public +export type ReleaseManifest = { + releaseVersion: string; + packages: { + name: string; + version: string; + }[]; +}; +``` diff --git a/packages/release-manifests/package.json b/packages/release-manifests/package.json new file mode 100644 index 0000000000..405896f50b --- /dev/null +++ b/packages/release-manifests/package.json @@ -0,0 +1,44 @@ +{ + "name": "@backstage/release-manifests", + "description": "Helper library for receiving release manifests", + "version": "0.0.0", + "private": false, + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/release-manifests" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "scripts": { + "build": "backstage-cli build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "cross-fetch": "^3.0.6" + }, + "devDependencies": { + "@backstage/test-utils": "^0.2.3", + "msw": "^0.35.0", + "@types/jest": "^26.0.7", + "@types/node": "^14.14.32" + }, + "files": [ + "dist" + ] +} diff --git a/packages/release-manifests/src/index.ts b/packages/release-manifests/src/index.ts new file mode 100644 index 0000000000..f8ea22a3cb --- /dev/null +++ b/packages/release-manifests/src/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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. + */ + +/** + * Contains mapping between Backstage release and package versions. + * + * @packageDocumentation + */ + +export { getManifestByVersion, getManifestByReleaseLine } from './manifest'; +export type { + ReleaseManifest, + GetManifestByReleaseLineOptions, + GetManifestByVersionOptions, +} from './manifest'; diff --git a/packages/release-manifests/src/manifest.test.ts b/packages/release-manifests/src/manifest.test.ts new file mode 100644 index 0000000000..70894e5431 --- /dev/null +++ b/packages/release-manifests/src/manifest.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { setupRequestMockHandlers } from '@backstage/test-utils'; +import { getManifestByReleaseLine, getManifestByVersion } from './manifest'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; + +describe('getManifestByVersion', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + it('should return a list of packages in a release', async () => { + worker.use( + rest.get('*/v1/releases/0.0.0/manifest.json', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + packages: [{ name: '@backstage/core', version: '1.2.3' }], + }), + ), + ), + rest.get('*/v1/releases/999.0.1/manifest.json', (_, res, ctx) => + res(ctx.status(404), ctx.json({})), + ), + ); + + const pkgs = await getManifestByVersion({ version: '0.0.0' }); + expect(pkgs.packages).toEqual([ + { + name: '@backstage/core', + version: '1.2.3', + }, + ]); + + await expect(getManifestByVersion({ version: '999.0.1' })).rejects.toThrow( + 'No release found for 999.0.1 version', + ); + }); +}); + +describe('getManifestByReleaseLine', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + + it('should return a list of packages in a release', async () => { + worker.use( + rest.get('*/v1/tags/main/manifest.json', (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + packages: [{ name: '@backstage/core', version: '1.2.3' }], + }), + ), + ), + rest.get('*/v1/tags/foo/manifest.json', (_, res, ctx) => + res(ctx.status(404), ctx.json({})), + ), + ); + + const pkgs = await getManifestByReleaseLine({ releaseLine: 'main' }); + expect(pkgs.packages).toEqual([ + { + name: '@backstage/core', + version: '1.2.3', + }, + ]); + + await expect( + getManifestByReleaseLine({ releaseLine: 'foo' }), + ).rejects.toThrow("No 'foo' release line found"); + }); +}); diff --git a/packages/release-manifests/src/manifest.ts b/packages/release-manifests/src/manifest.ts new file mode 100644 index 0000000000..955cf7308c --- /dev/null +++ b/packages/release-manifests/src/manifest.ts @@ -0,0 +1,88 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 fetch from 'cross-fetch'; + +const VERSIONS_DOMAIN = 'https://versions.backstage.io'; + +/** + * Contains mapping between Backstage release and package versions. + * @public + */ +export type ReleaseManifest = { + releaseVersion: string; + packages: { name: string; version: string }[]; +}; + +/** + * Options for {@link getManifestByVersion}. + * @public + */ +export type GetManifestByVersionOptions = { + version: string; +}; + +/** + * Returns a release manifest based on supplied version. + * @public + */ +export async function getManifestByVersion( + options: GetManifestByVersionOptions, +): Promise { + const url = `${VERSIONS_DOMAIN}/v1/releases/${encodeURIComponent( + options.version, + )}/manifest.json`; + const response = await fetch(url); + if (response.status === 404) { + throw new Error(`No release found for ${options.version} version`); + } + if (response.status !== 200) { + throw new Error( + `Unexpected response status ${response.status} when fetching release from ${url}.`, + ); + } + return await response.json(); +} + +/** + * Options for {@link getManifestByReleaseLine}. + * @public + */ +export type GetManifestByReleaseLineOptions = { + releaseLine: string; +}; + +/** + * Returns a release manifest based on supplied release line. + * @public + */ +export async function getManifestByReleaseLine( + options: GetManifestByReleaseLineOptions, +): Promise { + const url = `${VERSIONS_DOMAIN}/v1/tags/${encodeURIComponent( + options.releaseLine, + )}/manifest.json`; + const response = await fetch(url); + if (response.status === 404) { + throw new Error(`No '${options.releaseLine}' release line found`); + } + if (response.status !== 200) { + throw new Error( + `Unexpected response status ${response.status} when fetching release from ${url}.`, + ); + } + return await response.json(); +} diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 1ac43d242d..808af8754d 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -216,6 +216,7 @@ const NO_WARNING_PACKAGES = [ 'packages/test-utils', 'packages/theme', 'packages/types', + 'packages/release-manifests', 'packages/version-bridge', 'plugins/catalog-backend-module-ldap', 'plugins/catalog-backend-module-msgraph',