Merge pull request #31122 from drodil/yarn_plugin_custom_url

feat(yarn-plugin): custom manifest location
This commit is contained in:
Patrik Oldsberg
2025-10-14 14:39:55 +02:00
committed by GitHub
6 changed files with 863 additions and 44 deletions
+20
View File
@@ -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_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.
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.
@@ -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
@@ -1056,3 +1055,310 @@ describe('createVersionFinder', () => {
);
});
});
describe('environment variables', () => {
const worker = setupServer();
registerMswTestHooks(worker);
beforeEach(() => {
delete process.env.BACKSTAGE_MANIFEST_FILE;
process.env.BACKSTAGE_VERSIONS_BASE_URL = 'https://custom.example.com';
});
afterEach(() => {
jest.resetAllMocks();
});
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({
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_VERSIONS_BASE_URL is set with yarn plugin', async () => {
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 () => {
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();
});
});
@@ -16,6 +16,7 @@
maybeBootstrapProxy();
import { env } from 'process';
import fs from 'fs-extra';
import chalk from 'chalk';
import { minimatch } from 'minimatch';
@@ -26,9 +27,9 @@ import { resolve as resolvePath } from 'path';
import { paths } from '../../../../lib/paths';
import { getHasYarnPlugin } from '../../../../lib/yarnPlugin';
import {
mapDependencies,
fetchPackageInfo,
Lockfile,
mapDependencies,
YarnInfoInspectData,
} from '../../../../lib/versioning';
import { BACKSTAGE_JSON } from '@backstage/cli-common';
@@ -104,8 +105,14 @@ export default async (opts: OptionValues) => {
let findTargetVersion: (name: string) => Promise<string>;
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,
@@ -115,9 +122,11 @@ export default async (opts: OptionValues) => {
if (opts.release === 'next') {
const next = await getManifestByReleaseLine({
releaseLine: 'next',
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
});
const main = await getManifestByReleaseLine({
releaseLine: 'main',
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
});
// Prefer manifest with the latest release version
releaseManifest = semver.gt(next.releaseVersion, main.releaseVersion)
@@ -126,6 +135,7 @@ export default async (opts: OptionValues) => {
} else {
releaseManifest = await getManifestByReleaseLine({
releaseLine: opts.release,
versionsBaseUrl: env.BACKSTAGE_VERSIONS_BASE_URL,
});
}
findTargetVersion = createVersionFinder({
@@ -140,11 +150,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_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]);
console.log();
}
+15
View File
@@ -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_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.
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`
@@ -0,0 +1,462 @@
/*
* 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<typeof httpUtils>;
const mockStructUtils = structUtils as jest.Mocked<typeof structUtils>;
const mockGetManifestByVersion = getManifestByVersion as jest.MockedFunction<
typeof getManifestByVersion
>;
const mockGetCurrentBackstageVersion =
getCurrentBackstageVersion as jest.MockedFunction<
typeof getCurrentBackstageVersion
>;
describe('getPackageVersion', () => {
const mockConfiguration = {} as Configuration;
beforeEach(() => {
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] || '',
}));
});
afterEach(() => {
jest.clearAllMocks();
});
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_VERSIONS_BASE_URL;
process.env.BACKSTAGE_VERSIONS_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_VERSIONS_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();
});
});
});
@@ -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_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:
//
// 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) {