diff --git a/.changeset/heavy-chicken-find.md b/.changeset/heavy-chicken-find.md new file mode 100644 index 0000000000..96308ed2b8 --- /dev/null +++ b/.changeset/heavy-chicken-find.md @@ -0,0 +1,20 @@ +--- +'@backstage/cli': patch +--- + +Allow using custom manifest location in the yarn plugin and version bump. + +The Backstage yarn plugin and version bump allows two new environment variables to configure custom manifest location: + +- `BACKSTAGE_MANIFEST_BASE_URL`: The base URL for fetching the Backstage version + manifest. Defaults to `https://versions.backstage.io/v1/releases/VERSION/manifest.json`. + Useful for running the plugin in environment without direct access to the internet, + for example by using a mirror of the versions API or a proxy. + Note that the environment variable is just the host name, and the path is appended by + the plugin. If you are using the yarn plugin, bump version command will also try + to fetch the new version of the yarn plugin from the same base URL (defaults to + `https://versions.backstage.io/v1/releases/RELEASE/yarn-plugin`) +- `BACKSTAGE_MANIFEST_FILE`: Path to a local manifest file. If set, the plugin + will not attempt to fetch the manifest from the network. Useful for running + the plugin in environment without internet access and without mirror of the + versions API. diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts index a309ddb192..bea7a551a1 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Backstage Authors + * Copyright 2025 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import fs from 'fs-extra'; import { Command } from 'commander'; import * as runObj from '../../../../lib/run'; @@ -24,8 +23,8 @@ import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { NotFoundError } from '@backstage/errors'; import { - MockDirectory, createMockDirectory, + MockDirectory, } from '@backstage/backend-test-utils'; // Avoid mutating the global agents used in other tests @@ -1055,3 +1054,313 @@ describe('createVersionFinder', () => { ); }); }); + +describe('environment variables', () => { + const worker = setupServer(); + registerMswTestHooks(worker); + + beforeEach(() => { + delete process.env.BACKSTAGE_MANIFEST_FILE; + delete process.env.BACKSTAGE_MANIFEST_BASE_URL; + }); + + afterEach(() => { + jest.resetAllMocks(); + delete process.env.BACKSTAGE_MANIFEST_FILE; + delete process.env.BACKSTAGE_MANIFEST_BASE_URL; + }); + + it('should use custom base URL when BACKSTAGE_MANIFEST_BASE_URL is set', async () => { + process.env.BACKSTAGE_MANIFEST_BASE_URL = 'https://custom.example.com'; + + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ + workspaces: { + packages: ['packages/*'], + }, + }), + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), + }, + }, + }); + + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + worker.use( + rest.get( + 'https://custom.example.com/v1/tags/main/manifest.json', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + releaseVersion: '1.5.0', + packages: [ + { + name: '@backstage/core', + version: '1.5.0', + }, + ], + }), + ), + ), + ); + + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { + await bump({ pattern: null, release: 'main' } as unknown as Command); + }); + + expectLogsToMatch(logs, [ + 'Using default pattern glob @backstage/*', + 'Checking for updates of @backstage/core', + 'Some packages are outdated, updating', + 'bumping @backstage/core in a to ^1.5.0', + 'Your project is now at version 1.5.0, which has been written to backstage.json', + 'Running yarn install to install new versions', + 'Checking for moved packages to the @backstage-community namespace...', + 'Version bump complete!', + ]); + + expect(runObj.run).toHaveBeenCalledTimes(1); + expect(runObj.run).toHaveBeenCalledWith( + 'yarn', + ['install'], + expect.any(Object), + ); + + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); + expect(packageA).toEqual({ + name: 'a', + dependencies: { + '@backstage/core': '^1.5.0', + }, + }); + }); + + it('should use custom manifest file when BACKSTAGE_MANIFEST_FILE is set', async () => { + const manifestPath = mockDir.resolve('custom-manifest.json'); + process.env.BACKSTAGE_MANIFEST_FILE = manifestPath; + + const customManifest = { + releaseVersion: '2.0.0', + packages: [ + { + name: '@backstage/core', + version: '2.0.0', + }, + { + name: '@backstage/theme', + version: '3.0.0', + }, + ], + }; + + mockDir.setContent({ + 'custom-manifest.json': JSON.stringify(customManifest), + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ + workspaces: { + packages: ['packages/*'], + }, + }), + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + '@backstage/theme': '^1.0.0', + }, + }), + }, + }, + }); + + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { + await bump({ pattern: null, release: 'main' } as unknown as Command); + }); + + expectLogsToMatch(logs, [ + 'Using default pattern glob @backstage/*', + 'Checking for updates of @backstage/core', + 'Checking for updates of @backstage/theme', + 'Some packages are outdated, updating', + 'bumping @backstage/core in a to ^2.0.0', + 'bumping @backstage/theme in a to ^3.0.0', + 'Your project is now at version 2.0.0, which has been written to backstage.json', + 'Running yarn install to install new versions', + 'Checking for moved packages to the @backstage-community namespace...', + '⚠️ The following packages may have breaking changes:', + ' @backstage/core : 1.0.6 ~> 2.0.0', + ' https://github.com/backstage/backstage/blob/master/packages/core/CHANGELOG.md', + ' @backstage/theme : 1.0.0 ~> 3.0.0', + ' https://github.com/backstage/backstage/blob/master/packages/theme/CHANGELOG.md', + 'Version bump complete!', + ]); + + // Should not make any HTTP requests since using local manifest + expect(mockFetchPackageInfo).not.toHaveBeenCalled(); + + expect(runObj.run).toHaveBeenCalledTimes(1); + expect(runObj.run).toHaveBeenCalledWith( + 'yarn', + ['install'], + expect.any(Object), + ); + + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); + expect(packageA).toEqual({ + name: 'a', + dependencies: { + '@backstage/core': '^2.0.0', + '@backstage/theme': '^3.0.0', + }, + }); + }); + + it('should use custom base URL for yarn plugin when BACKSTAGE_MANIFEST_BASE_URL is set with yarn plugin', async () => { + process.env.BACKSTAGE_MANIFEST_BASE_URL = 'https://custom.example.com'; + + mockDir.setContent({ + '.yarnrc.yml': yarnRcMock, + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ + workspaces: { + packages: ['packages/*'], + }, + }), + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), + }, + }, + }); + + jest.spyOn(runObj, 'run').mockResolvedValue(undefined); + worker.use( + rest.get( + 'https://custom.example.com/v1/tags/main/manifest.json', + (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + releaseVersion: '1.5.0', + packages: [ + { + name: '@backstage/core', + version: '1.5.0', + }, + ], + }), + ), + ), + ); + + const { log: logs } = await withLogCollector(['log', 'warn'], async () => { + await bump({ pattern: null, release: 'main' } as unknown as Command); + }); + + expectLogsToMatch(logs, [ + 'Using default pattern glob @backstage/*', + 'Checking for updates of @backstage/core', + 'NOTE: this bump used backstage:^ versions in package.json files, since the Backstage yarn plugin was detected in the repository. To migrate back to explicit npm versions, remove the plugin by running "yarn plugin remove @yarnpkg/plugin-backstage", then repeat this command.', + 'Some packages are outdated, updating', + 'bumping @backstage/core in a to ^1.5.0', + 'Updating yarn plugin to v1.5.0...', + 'Your project is now at version 1.5.0, which has been written to backstage.json', + 'Running yarn install to install new versions', + 'Checking for moved packages to the @backstage-community namespace...', + 'Version bump complete!', + ]); + + expect(runObj.run).toHaveBeenCalledTimes(2); + expect(runObj.run).toHaveBeenCalledWith('yarn', [ + 'plugin', + 'import', + 'https://custom.example.com/v1/releases/1.5.0/yarn-plugin', + ]); + expect(runObj.run).toHaveBeenCalledWith( + 'yarn', + ['install'], + expect.any(Object), + ); + }); + + it('should handle missing manifest file when BACKSTAGE_MANIFEST_FILE is set', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = '/nonexistent/manifest.json'; + + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ + workspaces: { + packages: ['packages/*'], + }, + }), + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), + }, + }, + }); + + await expect( + bump({ pattern: null, release: 'main' } as unknown as Command), + ).rejects.toThrow(); + }); + + it('should handle network errors when using custom base URL', async () => { + process.env.BACKSTAGE_MANIFEST_BASE_URL = 'https://custom.example.com'; + + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ + workspaces: { + packages: ['packages/*'], + }, + }), + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), + }, + }, + }); + + worker.use( + rest.get( + 'https://custom.example.com/v1/tags/main/manifest.json', + (_, res, ctx) => res(ctx.status(500), ctx.json({})), + ), + ); + + await expect( + bump({ pattern: null, release: 'main' } as unknown as Command), + ).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index 23202ee1b5..b4c21be58c 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -16,6 +16,7 @@ maybeBootstrapProxy(); +import { env } from 'process'; import fs from 'fs-extra'; import chalk from 'chalk'; import semver from 'semver'; @@ -26,9 +27,9 @@ import { isError, NotFoundError } from '@backstage/errors'; import { resolve as resolvePath } from 'path'; import { paths } from '../../../../lib/paths'; import { - mapDependencies, fetchPackageInfo, Lockfile, + mapDependencies, YarnInfoInspectData, } from '../../../../lib/versioning'; import { BACKSTAGE_JSON } from '@backstage/cli-common'; @@ -96,8 +97,14 @@ export default async (opts: OptionValues) => { let findTargetVersion: (name: string) => Promise; let releaseManifest: ReleaseManifest; - // Specific release specified. Be strict when resolving versions - if (semver.valid(opts.release)) { + if (env.BACKSTAGE_MANIFEST_FILE) { + // Use specific manifest file if provided + releaseManifest = await fs.readJson(env.BACKSTAGE_MANIFEST_FILE); + findTargetVersion = createStrictVersionFinder({ + releaseManifest, + }); + } else if (semver.valid(opts.release)) { + // Specific release specified. Be strict when resolving versions releaseManifest = await getManifestByVersion({ version: opts.release }); findTargetVersion = createStrictVersionFinder({ releaseManifest, @@ -107,9 +114,11 @@ export default async (opts: OptionValues) => { if (opts.release === 'next') { const next = await getManifestByReleaseLine({ releaseLine: 'next', + versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, }); const main = await getManifestByReleaseLine({ releaseLine: 'main', + versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, }); // Prefer manifest with the latest release version releaseManifest = semver.gt(next.releaseVersion, main.releaseVersion) @@ -118,6 +127,7 @@ export default async (opts: OptionValues) => { } else { releaseManifest = await getManifestByReleaseLine({ releaseLine: opts.release, + versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, }); } findTargetVersion = createVersionFinder({ @@ -132,11 +142,12 @@ export default async (opts: OptionValues) => { `Updating yarn plugin to v${releaseManifest.releaseVersion}...`, ); console.log(); - await run('yarn', [ - 'plugin', - 'import', - `https://versions.backstage.io/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin`, - ]); + + const yarnPluginUrl = env.BACKSTAGE_MANIFEST_BASE_URL + ? `${env.BACKSTAGE_MANIFEST_BASE_URL}/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin` + : `https://versions.backstage.io/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin`; + + await run('yarn', ['plugin', 'import', yarnPluginUrl]); console.log(); }