feat(cli): also support custom manifest in version bump

Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
This commit is contained in:
Hellgren Heikki
2025-09-12 09:02:52 +03:00
parent d4b682feaf
commit 33faad237f
3 changed files with 351 additions and 11 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_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.
@@ -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();
});
});
@@ -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<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,
@@ -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();
}