From d4b682feafb757d601972a79bd14973e5d1ecbcd Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 11 Sep 2025 22:24:11 +0300 Subject: [PATCH 1/3] feat(yarn-plugin): custom manifest location added two environment variables that can be used to control the location of backstage manifest file used by the yarn plugin. this helps using the plugin in restricted environments without direct or access at all to the internet. closes #31101 Signed-off-by: Hellgren Heikki --- packages/yarn-plugin/README.md | 15 + .../src/util/getPackageVersion.test.ts | 460 ++++++++++++++++++ .../yarn-plugin/src/util/getPackageVersion.ts | 71 +-- 3 files changed, 513 insertions(+), 33 deletions(-) create mode 100644 packages/yarn-plugin/src/util/getPackageVersion.test.ts diff --git a/packages/yarn-plugin/README.md b/packages/yarn-plugin/README.md index 5c112506e3..fe90cebe7b 100644 --- a/packages/yarn-plugin/README.md +++ b/packages/yarn-plugin/README.md @@ -25,6 +25,21 @@ The yarn plugin recognizes the version string `"backstage:^"`, and replaces it with the appropriate version based on the overall Backstage version in backstage.json. +### Configuration + +The plugin functionality can be configured with environment variables: + +- `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. +- `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. + ## Local Development - Run unit tests: `yarn test` diff --git a/packages/yarn-plugin/src/util/getPackageVersion.test.ts b/packages/yarn-plugin/src/util/getPackageVersion.test.ts new file mode 100644 index 0000000000..d093a8f3ad --- /dev/null +++ b/packages/yarn-plugin/src/util/getPackageVersion.test.ts @@ -0,0 +1,460 @@ +/* + * Copyright 2024 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 { + Configuration, + Descriptor, + httpUtils, + structUtils, +} from '@yarnpkg/core'; +import { xfs } from '@yarnpkg/fslib'; +import { getManifestByVersion } from '@backstage/release-manifests'; +import { getPackageVersion } from './getPackageVersion'; +import { getCurrentBackstageVersion } from './getCurrentBackstageVersion'; + +jest.mock('@yarnpkg/core'); +jest.mock('@backstage/release-manifests'); +jest.mock('./getCurrentBackstageVersion'); + +const mockHttpUtils = httpUtils as jest.Mocked; +const mockStructUtils = structUtils as jest.Mocked; +const mockGetManifestByVersion = getManifestByVersion as jest.MockedFunction< + typeof getManifestByVersion +>; +const mockGetCurrentBackstageVersion = + getCurrentBackstageVersion as jest.MockedFunction< + typeof getCurrentBackstageVersion + >; + +describe('getPackageVersion', () => { + const mockConfiguration = {} as Configuration; + + beforeEach(() => { + jest.clearAllMocks(); + + mockGetCurrentBackstageVersion.mockReturnValue('1.23.4'); + mockStructUtils.stringifyIdent.mockImplementation( + (descriptor: any) => + `${descriptor.scope || ''}${descriptor.scope ? '/' : ''}${ + descriptor.name + }`, + ); + + mockStructUtils.parseRange.mockImplementation((range: string) => ({ + protocol: range.includes('backstage:') ? 'backstage:' : 'npm:', + selector: range.includes('^') ? '^' : range.split(':')[1] || '', + })); + }); + + describe('successful package resolution', () => { + beforeEach(() => { + mockHttpUtils.get.mockResolvedValue({ + packages: [ + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + { name: '@backstage/core-app-api', version: '1.12.0' }, + ], + }); + + mockGetManifestByVersion.mockResolvedValue({ + releaseVersion: '1.23.4', + packages: [ + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + { name: '@backstage/core-app-api', version: '1.12.0' }, + ], + }); + }); + + it('returns the correct version for a package in the manifest', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const result = await getPackageVersion(descriptor, mockConfiguration); + + expect(result).toBe('1.9.0'); + expect(mockGetCurrentBackstageVersion).toHaveBeenCalledTimes(1); + expect(mockGetManifestByVersion).toHaveBeenCalledWith({ + version: '1.23.4', + versionsBaseUrl: undefined, + fetch: expect.any(Function), + }); + }); + + it('uses custom versionsBaseUrl from environment when provided', async () => { + const originalEnv = process.env.BACKSTAGE_MANIFEST_BASE_URL; + process.env.BACKSTAGE_MANIFEST_BASE_URL = 'https://custom.example.com'; + + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + await getPackageVersion(descriptor, mockConfiguration); + + expect(mockGetManifestByVersion).toHaveBeenCalledWith({ + version: '1.23.4', + versionsBaseUrl: 'https://custom.example.com', + fetch: expect.any(Function), + }); + + process.env.BACKSTAGE_MANIFEST_BASE_URL = originalEnv; + }); + + it('uses yarn httpUtils for fetching with proper response format', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const mockResponse = { + packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }], + }; + + mockHttpUtils.get.mockResolvedValue(mockResponse); + + await getPackageVersion(descriptor, mockConfiguration); + + expect(mockGetManifestByVersion).toHaveBeenCalledTimes(1); + + // Get the fetch function that was passed to getManifestByVersion + const fetchFunction = mockGetManifestByVersion.mock.calls[0][0].fetch; + + // Test the custom fetch function + const testUrl = 'https://example.com/manifest.json'; + const fetchResult = await fetchFunction!(testUrl); + + expect(mockHttpUtils.get).toHaveBeenCalledWith(testUrl, { + configuration: mockConfiguration, + jsonResponse: true, + }); + + expect(fetchResult).toEqual({ + status: 200, + url: testUrl, + json: expect.any(Function), + }); + + expect(await fetchResult.json()).toEqual(mockResponse); + }); + }); + + describe('error cases', () => { + it('throws error for unsupported protocol', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'npm:^1.0.0', + } as Descriptor; + + mockStructUtils.parseRange.mockReturnValue({ + protocol: 'npm:', + selector: '^1.0.0', + }); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow( + 'Unsupported version protocol in version range "npm:^1.0.0" for package @backstage/core-plugin-api', + ); + }); + + it('throws error for unexpected version selector', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:~1.0.0', + } as Descriptor; + + mockStructUtils.parseRange.mockReturnValue({ + protocol: 'backstage:', + selector: '~1.0.0', + }); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow( + 'Unexpected version selector "~1.0.0" for package @backstage/core-plugin-api', + ); + }); + + it('throws error when package is not found in manifest', async () => { + const descriptor = { + scope: '@backstage', + name: 'non-existent-package', + range: 'backstage:^', + } as Descriptor; + + mockGetManifestByVersion.mockResolvedValue({ + releaseVersion: '1.23.4', + packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }], + }); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow( + 'Package @backstage/non-existent-package not found in manifest for Backstage v1.23.4. ' + + 'This means the specified package is not included in this Backstage ' + + 'release. This may imply the package has been replaced with an alternative - ' + + 'please review the documentation for the package. If you need to continue ' + + 'using this package, it will be necessary to switch to manually managing its ' + + 'version.', + ); + }); + + it('propagates errors from getManifestByVersion', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const manifestError = new Error('Failed to fetch manifest'); + mockGetManifestByVersion.mockRejectedValue(manifestError); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow('Failed to fetch manifest'); + }); + + it('propagates errors from getCurrentBackstageVersion', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const versionError = new Error('Failed to get current version'); + mockGetCurrentBackstageVersion.mockImplementation(() => { + throw versionError; + }); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow('Failed to get current version'); + }); + }); + + describe('different package name formats', () => { + beforeEach(() => { + mockGetManifestByVersion.mockResolvedValue({ + releaseVersion: '1.23.4', + packages: [ + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + { name: 'backstage-plugin-simple', version: '0.5.0' }, + { name: '@internal/custom-package', version: '2.1.0' }, + ], + }); + }); + + it('handles scoped packages correctly', async () => { + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const result = await getPackageVersion(descriptor, mockConfiguration); + expect(result).toBe('1.9.0'); + }); + + it('handles non-scoped packages correctly', async () => { + const descriptor = { + name: 'backstage-plugin-simple', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue('backstage-plugin-simple'); + + const result = await getPackageVersion(descriptor, mockConfiguration); + expect(result).toBe('0.5.0'); + }); + + it('handles packages with different scopes', async () => { + const descriptor = { + scope: '@internal', + name: 'custom-package', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue( + '@internal/custom-package', + ); + + const result = await getPackageVersion(descriptor, mockConfiguration); + expect(result).toBe('2.1.0'); + }); + }); + + describe('BACKSTAGE_MANIFEST_FILE environment variable', () => { + const manifestFilePath = '/path/to/manifest.json'; + + beforeEach(() => { + // Clean up environment variables + delete process.env.BACKSTAGE_MANIFEST_FILE; + }); + + afterEach(() => { + // Clean up environment variables + delete process.env.BACKSTAGE_MANIFEST_FILE; + }); + + it('reads manifest from file when BACKSTAGE_MANIFEST_FILE is set', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const mockManifest = { + packages: [ + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + { name: '@backstage/core-app-api', version: '1.12.0' }, + ], + }; + + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockReturnValue(mockManifest); + + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + const result = await getPackageVersion(descriptor, mockConfiguration); + + expect(result).toBe('1.9.0'); + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + expect(mockGetManifestByVersion).not.toHaveBeenCalled(); + expect(mockHttpUtils.get).not.toHaveBeenCalled(); + }); + + it('finds package in file-based manifest', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const mockManifest = { + packages: [ + { name: '@backstage/core-plugin-api', version: '2.1.0' }, + { name: '@backstage/backend-common', version: '0.19.5' }, + { name: 'simple-package', version: '1.0.0' }, + ], + }; + + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockReturnValue(mockManifest); + + const descriptor = { + scope: '@backstage', + name: 'backend-common', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue( + '@backstage/backend-common', + ); + + const result = await getPackageVersion(descriptor, mockConfiguration); + + expect(result).toBe('0.19.5'); + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + }); + + it('throws error when package not found in file-based manifest', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const mockManifest = { + packages: [{ name: '@backstage/core-plugin-api', version: '1.9.0' }], + }; + + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockReturnValue(mockManifest); + + const descriptor = { + scope: '@backstage', + name: 'missing-package', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue( + '@backstage/missing-package', + ); + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow( + 'Package @backstage/missing-package not found in manifest for Backstage v1.23.4. ' + + 'This means the specified package is not included in this Backstage ' + + 'release. This may imply the package has been replaced with an alternative - ' + + 'please review the documentation for the package. If you need to continue ' + + 'using this package, it will be necessary to switch to manually managing its ' + + 'version.', + ); + + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + expect(mockGetManifestByVersion).not.toHaveBeenCalled(); + }); + + it('propagates errors from xfs.readJsonSync when file cannot be read', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const fileError = new Error('ENOENT: no such file or directory'); + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockImplementation(() => { + throw fileError; + }); + + const descriptor = { + scope: '@backstage', + name: 'core-plugin-api', + range: 'backstage:^', + } as Descriptor; + + await expect( + getPackageVersion(descriptor, mockConfiguration), + ).rejects.toThrow('ENOENT: no such file or directory'); + + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + expect(mockGetManifestByVersion).not.toHaveBeenCalled(); + }); + + it('handles non-scoped packages in file-based manifest', async () => { + process.env.BACKSTAGE_MANIFEST_FILE = manifestFilePath; + + const mockManifest = { + packages: [ + { name: 'backstage-plugin-simple', version: '0.5.0' }, + { name: '@backstage/core-plugin-api', version: '1.9.0' }, + ], + }; + + const readJsonSyncSpy = jest.spyOn(xfs, 'readJsonSync'); + readJsonSyncSpy.mockReturnValue(mockManifest); + + const descriptor = { + name: 'backstage-plugin-simple', + range: 'backstage:^', + } as Descriptor; + + mockStructUtils.stringifyIdent.mockReturnValue('backstage-plugin-simple'); + + const result = await getPackageVersion(descriptor, mockConfiguration); + + expect(result).toBe('0.5.0'); + expect(readJsonSyncSpy).toHaveBeenCalledWith(manifestFilePath); + expect(mockGetManifestByVersion).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/yarn-plugin/src/util/getPackageVersion.ts b/packages/yarn-plugin/src/util/getPackageVersion.ts index 25ff795c06..ed1c1ee6d8 100644 --- a/packages/yarn-plugin/src/util/getPackageVersion.ts +++ b/packages/yarn-plugin/src/util/getPackageVersion.ts @@ -20,10 +20,12 @@ import { httpUtils, structUtils, } from '@yarnpkg/core'; +import { PortablePath, xfs } from '@yarnpkg/fslib'; import { getManifestByVersion } from '@backstage/release-manifests'; import { PROTOCOL } from '../constants'; import { getCurrentBackstageVersion } from './getCurrentBackstageVersion'; +import { env } from 'process'; export const getPackageVersion = async ( descriptor: Descriptor, @@ -45,43 +47,46 @@ export const getPackageVersion = async ( } const backstageVersion = getCurrentBackstageVersion(); + const manifestFile = env.BACKSTAGE_MANIFEST_FILE; + const manifest = manifestFile + ? await xfs.readJsonSync(manifestFile as PortablePath) + : await getManifestByVersion({ + version: backstageVersion, + versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, + // We override the fetch function used inside getManifestByVersion with a + // custom implementation that calls yarn's built-in `httpUtils` method + // instead. This has a couple of benefits: + // + // 1. This means that the fetch should leverage yarn's built-in cache, so we + // don't need to explicitly memoize the fetch. + // 2. The request should automatically take account of any proxy settings + // configured in yarn. + fetch: async (url: string) => { + const response = await httpUtils.get(url, { + configuration, + jsonResponse: true, + }); - const manifest = await getManifestByVersion({ - version: backstageVersion, - // We override the fetch function used inside getManifestByVersion with a - // custom implementation that calls yarn's built-in `httpUtils` method - // instead. This has a couple of benefits: - // - // 1. This means that the fetch should leverage yarn's built-in cache, so we - // don't need to explicitly memoize the fetch. - // 2. The request should automatically take account of any proxy settings - // configured in yarn. - fetch: async (url: string) => { - const response = await httpUtils.get(url, { - configuration, - jsonResponse: true, + // The release-manifests package expects fetchFn to resolve with a subset + // of the native HTTP Response object, but yarn's httpUtils implementation + // keeps most of the details hidden. This means we need to construct an + // object which quacks like a Response in the appropriate ways. + return { + // The function has some custom handling for non-200 errors. Yarn + // doesn't provide the status code, but if we've got to this point + // without throwing, we can assume the request has been successful. + status: 200, + // The requested URL, used to correctly report errors + url, + // Yarn automatically parses the response as JSON, so our implementation + // can simply return it. + json: () => response, + }; + }, }); - // The release-manifests package expects fetchFn to resolve with a subset - // of the native HTTP Response object, but yarn's httpUtils implementation - // keeps most of the details hidden. This means we need to construct an - // object which quacks like a Response in the appropriate ways. - return { - // The function has some custom handling for non-200 errors. Yarn - // doesn't provide the status code, but if we've got to this point - // without throwing, we can assume the request has been successful. - status: 200, - // The requested URL, used to correctly report errors - url, - // Yarn automatically parses the response as JSON, so our implementation - // can simply return it. - json: () => response, - }; - }, - }); - const manifestEntry = manifest.packages.find( - candidate => candidate.name === ident, + (candidate: { name: string; version: string }) => candidate.name === ident, ); if (!manifestEntry) { From 33faad237f81899bbc8bdb17e5b6738688ea1455 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Fri, 12 Sep 2025 09:02:52 +0300 Subject: [PATCH 2/3] feat(cli): also support custom manifest in version bump Signed-off-by: Hellgren Heikki --- .changeset/heavy-chicken-find.md | 20 ++ .../migrate/commands/versions/bump.test.ts | 315 +++++++++++++++++- .../modules/migrate/commands/versions/bump.ts | 27 +- 3 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 .changeset/heavy-chicken-find.md 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(); } From 606f2c1f71a851feebbc1adc09f307f7d8939156 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Fri, 12 Sep 2025 09:19:43 +0300 Subject: [PATCH 3/3] chore: use BACKSTAGE_VERSIONS_BASE_URL instead Signed-off-by: Hellgren Heikki --- .changeset/heavy-chicken-find.md | 2 +- .../migrate/commands/versions/bump.test.ts | 17 +++++++---------- .../modules/migrate/commands/versions/bump.ts | 10 +++++----- packages/yarn-plugin/README.md | 2 +- .../src/util/getPackageVersion.test.ts | 12 +++++++----- .../yarn-plugin/src/util/getPackageVersion.ts | 2 +- 6 files changed, 22 insertions(+), 23 deletions(-) diff --git a/.changeset/heavy-chicken-find.md b/.changeset/heavy-chicken-find.md index 96308ed2b8..645984b1bd 100644 --- a/.changeset/heavy-chicken-find.md +++ b/.changeset/heavy-chicken-find.md @@ -6,7 +6,7 @@ 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 +- `BACKSTAGE_VERSIONS_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. 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 bea7a551a1..c045ae26e6 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.test.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.test.ts @@ -1061,18 +1061,19 @@ describe('environment variables', () => { beforeEach(() => { delete process.env.BACKSTAGE_MANIFEST_FILE; - delete process.env.BACKSTAGE_MANIFEST_BASE_URL; + process.env.BACKSTAGE_VERSIONS_BASE_URL = 'https://custom.example.com'; }); 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'; + afterAll(() => { + delete process.env.BACKSTAGE_MANIFEST_FILE; + delete process.env.BACKSTAGE_VERSIONS_BASE_URL; + }); + it('should use custom base URL when BACKSTAGE_VERSIONS_BASE_URL is set', async () => { mockDir.setContent({ 'yarn.lock': lockfileMock, 'package.json': JSON.stringify({ @@ -1230,9 +1231,7 @@ describe('environment variables', () => { }); }); - 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'; - + it('should use custom base URL for yarn plugin when BACKSTAGE_VERSIONS_BASE_URL is set with yarn plugin', async () => { mockDir.setContent({ '.yarnrc.yml': yarnRcMock, 'yarn.lock': lockfileMock, @@ -1331,8 +1330,6 @@ describe('environment variables', () => { }); 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({ diff --git a/packages/cli/src/modules/migrate/commands/versions/bump.ts b/packages/cli/src/modules/migrate/commands/versions/bump.ts index b4c21be58c..6bce65cb10 100644 --- a/packages/cli/src/modules/migrate/commands/versions/bump.ts +++ b/packages/cli/src/modules/migrate/commands/versions/bump.ts @@ -114,11 +114,11 @@ export default async (opts: OptionValues) => { if (opts.release === 'next') { const next = await getManifestByReleaseLine({ releaseLine: 'next', - versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, + versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL, }); const main = await getManifestByReleaseLine({ releaseLine: 'main', - versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, + versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL, }); // Prefer manifest with the latest release version releaseManifest = semver.gt(next.releaseVersion, main.releaseVersion) @@ -127,7 +127,7 @@ export default async (opts: OptionValues) => { } else { releaseManifest = await getManifestByReleaseLine({ releaseLine: opts.release, - versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, + versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL, }); } findTargetVersion = createVersionFinder({ @@ -143,8 +143,8 @@ export default async (opts: OptionValues) => { ); console.log(); - const yarnPluginUrl = env.BACKSTAGE_MANIFEST_BASE_URL - ? `${env.BACKSTAGE_MANIFEST_BASE_URL}/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin` + const yarnPluginUrl = env.BACKSTAGE_VERSIONS_BASE_URL + ? `${env.BACKSTAGE_VERSIONS_BASE_URL}/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin` : `https://versions.backstage.io/v1/releases/${releaseManifest.releaseVersion}/yarn-plugin`; await run('yarn', ['plugin', 'import', yarnPluginUrl]); diff --git a/packages/yarn-plugin/README.md b/packages/yarn-plugin/README.md index fe90cebe7b..3249d4279c 100644 --- a/packages/yarn-plugin/README.md +++ b/packages/yarn-plugin/README.md @@ -29,7 +29,7 @@ backstage.json. The plugin functionality can be configured with environment variables: -- `BACKSTAGE_MANIFEST_BASE_URL` - The base URL for fetching the Backstage version +- `BACKSTAGE_VERSIONS_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. diff --git a/packages/yarn-plugin/src/util/getPackageVersion.test.ts b/packages/yarn-plugin/src/util/getPackageVersion.test.ts index d093a8f3ad..9eca738da1 100644 --- a/packages/yarn-plugin/src/util/getPackageVersion.test.ts +++ b/packages/yarn-plugin/src/util/getPackageVersion.test.ts @@ -43,8 +43,6 @@ describe('getPackageVersion', () => { const mockConfiguration = {} as Configuration; beforeEach(() => { - jest.clearAllMocks(); - mockGetCurrentBackstageVersion.mockReturnValue('1.23.4'); mockStructUtils.stringifyIdent.mockImplementation( (descriptor: any) => @@ -59,6 +57,10 @@ describe('getPackageVersion', () => { })); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('successful package resolution', () => { beforeEach(() => { mockHttpUtils.get.mockResolvedValue({ @@ -96,8 +98,8 @@ describe('getPackageVersion', () => { }); it('uses custom versionsBaseUrl from environment when provided', async () => { - const originalEnv = process.env.BACKSTAGE_MANIFEST_BASE_URL; - process.env.BACKSTAGE_MANIFEST_BASE_URL = 'https://custom.example.com'; + const originalEnv = process.env.BACKSTAGE_VERSIONS_BASE_URL; + process.env.BACKSTAGE_VERSIONS_BASE_URL = 'https://custom.example.com'; const descriptor = { scope: '@backstage', @@ -113,7 +115,7 @@ describe('getPackageVersion', () => { fetch: expect.any(Function), }); - process.env.BACKSTAGE_MANIFEST_BASE_URL = originalEnv; + process.env.BACKSTAGE_VERSIONS_BASE_URL = originalEnv; }); it('uses yarn httpUtils for fetching with proper response format', async () => { diff --git a/packages/yarn-plugin/src/util/getPackageVersion.ts b/packages/yarn-plugin/src/util/getPackageVersion.ts index ed1c1ee6d8..9ff7198c30 100644 --- a/packages/yarn-plugin/src/util/getPackageVersion.ts +++ b/packages/yarn-plugin/src/util/getPackageVersion.ts @@ -52,7 +52,7 @@ export const getPackageVersion = async ( ? await xfs.readJsonSync(manifestFile as PortablePath) : await getManifestByVersion({ version: backstageVersion, - versionsBaseUrl: env.BACKSTAGE_MANIFEST_BASE_URL, + versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL, // We override the fetch function used inside getManifestByVersion with a // custom implementation that calls yarn's built-in `httpUtils` method // instead. This has a couple of benefits: