Merge pull request #30091 from StateFarmIns/address_yarn_add

Address yarn add with backstage yarn plugin
This commit is contained in:
Fredrik Adelöw
2025-06-16 19:51:36 +02:00
committed by GitHub
11 changed files with 382 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'yarn-plugin-backstage': patch
---
added functionality so that adding or updating a backstage dependency to a package would maintain the "backstage:^" placeholder for the version.
+9
View File
@@ -82,3 +82,12 @@ specific Backstage repository. As such, when publishing packages, all
`backstage:^` versions should be removed from the package.json and replaced with
the appropriate npm version ranges. This is handled by the
`beforeWorkspacePacking` hook.
### `afterWorkspaceDependencyAddition` hook
_Replaces npm version ranges with `backstage:^` ranges for `@backstage/*` dependencies added after
the plugin has converted existing dependencies to `backstage:^` range_
### `afterWorkspaceDependencyReplacement` hook
_warns user with console message when running `yarn add` for a `@backstage/*` scoped dependency that is already a dependency in the target package. Doing so will remove the `backstage:^` scope and replace it with the actual npm version range, which may not be desired._
+2 -1
View File
@@ -32,8 +32,9 @@
"dependencies": {
"@backstage/cli-common": "workspace:^",
"@backstage/release-manifests": "workspace:^",
"@yarnpkg/core": "^4.4.0",
"@yarnpkg/core": "^4.4.1",
"@yarnpkg/fslib": "^3.1.2",
"@yarnpkg/plugin-essentials": "^4.4.0",
"@yarnpkg/plugin-npm": "patch:@yarnpkg/plugin-npm@npm%3A3.1.0#~/.yarn/patches/@yarnpkg-plugin-npm-npm-3.1.0-6533d0f5a1.patch",
"@yarnpkg/plugin-pack": "^4.0.1",
"semver": "^7.6.0"
+3 -2
View File
@@ -4,10 +4,11 @@
```ts
import { Hooks } from '@yarnpkg/core';
import { Hooks as Hooks_2 } from '@yarnpkg/plugin-pack';
import { Hooks as Hooks_2 } from '@yarnpkg/plugin-essentials';
import { Hooks as Hooks_3 } from '@yarnpkg/plugin-pack';
import { Plugin as Plugin_2 } from '@yarnpkg/core';
// @public (undocumented)
const plugin: Plugin_2<Hooks & Hooks_2>;
const plugin: Plugin_2<Hooks & Hooks_2 & Hooks_3>;
export default plugin;
```
@@ -0,0 +1,119 @@
/*
* 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.
* 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 {
Descriptor,
DescriptorHash,
IdentHash,
Workspace,
} from '@yarnpkg/core';
import { suggestUtils } from '@yarnpkg/plugin-essentials';
import { getPackageVersion } from '../util';
import { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition';
jest.mock('../util', () => ({
getPackageVersion: jest.fn(),
}));
describe('afterWorkspaceDependencyAddition', () => {
const workspace = {
project: {
configuration: {},
},
} as Workspace;
const target = {} as suggestUtils.Target;
const strategies: Array<suggestUtils.Strategy> = [];
const mockGetPackageVersion = getPackageVersion as jest.MockedFunction<
typeof getPackageVersion
>;
beforeEach(() => {
mockGetPackageVersion.mockReset();
});
it('should replace the range for a backstage scoped dependency', async () => {
const input: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
mockGetPackageVersion.mockImplementation(() => Promise.resolve('success'));
await afterWorkspaceDependencyAddition(
workspace,
target,
input,
strategies,
);
expect(input.range).toBe('backstage:^');
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
expect(mockGetPackageVersion).toHaveBeenCalledWith(
input,
workspace.project.configuration,
);
});
it('should not replace the range for a backstage scoped dependency where it cant find a version from remote', async () => {
const input: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
mockGetPackageVersion.mockImplementation(() =>
Promise.reject(new Error('test error')),
);
await afterWorkspaceDependencyAddition(
workspace,
target,
input,
strategies,
);
expect(input.range).toBe('^1.0.0');
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
expect(mockGetPackageVersion).toHaveBeenCalledWith(
input,
workspace.project.configuration,
);
});
it('should not replace the range for a non-backstage scoped dependency', async () => {
const input: Descriptor = {
scope: 'backstage-community',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
await afterWorkspaceDependencyAddition(
workspace,
target,
input,
strategies,
);
expect(input.range).toBe('^1.0.0');
expect(mockGetPackageVersion).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,46 @@
/*
* 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.
* 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 { Descriptor, structUtils, Workspace } from '@yarnpkg/core';
import { suggestUtils } from '@yarnpkg/plugin-essentials';
import { getPackageVersion } from '../util';
import { PROTOCOL } from '../constants';
export const afterWorkspaceDependencyAddition = async (
workspace: Workspace,
_target: suggestUtils.Target,
descriptor: Descriptor,
_strategies: Array<suggestUtils.Strategy>,
) => {
const descriptorRange = structUtils.parseRange(descriptor.range);
if (
descriptor.scope === 'backstage' &&
descriptorRange.protocol !== PROTOCOL
) {
try {
await getPackageVersion(descriptor, workspace.project.configuration);
console.info(
`Setting ${descriptor.scope}/${descriptor.name} to ${PROTOCOL}^`,
);
descriptor.range = `${PROTOCOL}^`;
} catch (_error: any) {
// if there's no found version then this is likely a deprecated package
// or otherwise the plugin won't be able to resolve the real version
// and we should leave the desired range as is
}
}
};
@@ -0,0 +1,138 @@
/*
* 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.
* 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 {
Descriptor,
DescriptorHash,
IdentHash,
Workspace,
} from '@yarnpkg/core';
import { suggestUtils } from '@yarnpkg/plugin-essentials';
import { getPackageVersion } from '../util';
import { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement';
jest.mock('../util', () => ({
getPackageVersion: jest.fn(),
}));
describe('afterWorkspaceDependencyReplacement.test', () => {
const workspace = {
project: {
configuration: {},
},
} as Workspace;
const target = {} as suggestUtils.Target;
const mockGetPackageVersion = getPackageVersion as jest.MockedFunction<
typeof getPackageVersion
>;
beforeEach(() => {
mockGetPackageVersion.mockReset();
});
it('should warn that the range is being changed for a backstage scoped dependency', async () => {
const fromDescriptor: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: 'backstage:^',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
const toDescriptor: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
mockGetPackageVersion.mockImplementation(() => Promise.resolve('success'));
await afterWorkspaceDependencyReplacement(
workspace,
target,
fromDescriptor,
toDescriptor,
);
expect(toDescriptor.range).toBe('^1.0.0');
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
expect(mockGetPackageVersion).toHaveBeenCalledWith(
toDescriptor,
workspace.project.configuration,
);
});
it('should not warn that the range is being changed for a backstage scoped dependency where it cant find a version from remote', async () => {
const fromDescriptor: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: 'backstage:^',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
const toDescriptor: Descriptor = {
scope: 'backstage',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
mockGetPackageVersion.mockImplementation(() =>
Promise.reject(new Error('test error')),
);
await afterWorkspaceDependencyReplacement(
workspace,
target,
fromDescriptor,
toDescriptor,
);
expect(toDescriptor.range).toBe('^1.0.0');
expect(mockGetPackageVersion).toHaveBeenCalledTimes(1);
expect(mockGetPackageVersion).toHaveBeenCalledWith(
toDescriptor,
workspace.project.configuration,
);
});
it('should ignore that the range is being changed for a non-backstage scoped dependency', async () => {
const fromDescriptor: Descriptor = {
scope: 'backstage-community',
name: 'test-package',
range: 'backstage:^',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
const toDescriptor: Descriptor = {
scope: 'backstage-community',
name: 'test-package',
range: '^1.0.0',
descriptorHash: {} as DescriptorHash,
identHash: {} as IdentHash,
};
await afterWorkspaceDependencyReplacement(
workspace,
target,
fromDescriptor,
toDescriptor,
);
expect(toDescriptor.range).toBe('^1.0.0');
expect(mockGetPackageVersion).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,45 @@
/*
* 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.
* 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 { Descriptor, structUtils, Workspace } from '@yarnpkg/core';
import { suggestUtils } from '@yarnpkg/plugin-essentials';
import { getPackageVersion } from '../util';
import { PROTOCOL } from '../constants';
export const afterWorkspaceDependencyReplacement = async (
workspace: Workspace,
_target: suggestUtils.Target,
_fromDescriptor: Descriptor,
toDescriptor: Descriptor,
) => {
const toDescriptorRange = structUtils.parseRange(toDescriptor.range);
if (
toDescriptor.scope === 'backstage' &&
toDescriptorRange.protocol !== PROTOCOL
) {
try {
await getPackageVersion(toDescriptor, workspace.project.configuration);
console.warn(
`${toDescriptor.name} should be set to "${PROTOCOL}^" instead of "${toDescriptor.range}". Make sure this change is intentional and not a mistake.`,
);
} catch (_error: any) {
// if there's no found version then this is likely a deprecated package
// or otherwise the plugin won't be able to resolve the real version
// and we should not warn them.
}
}
};
@@ -16,3 +16,5 @@
export { beforeWorkspacePacking } from './beforeWorkspacePacking';
export { reduceDependency } from './reduceDependency';
export { afterWorkspaceDependencyAddition } from './afterWorkspaceDependencyAddition';
export { afterWorkspaceDependencyReplacement } from './afterWorkspaceDependencyReplacement';
+10 -2
View File
@@ -23,7 +23,13 @@
import { Plugin, Hooks, semverUtils, YarnVersion } from '@yarnpkg/core';
import { Hooks as PackHooks } from '@yarnpkg/plugin-pack';
import { beforeWorkspacePacking, reduceDependency } from './handlers';
import { Hooks as EssentialHooks } from '@yarnpkg/plugin-essentials';
import {
afterWorkspaceDependencyAddition,
afterWorkspaceDependencyReplacement,
beforeWorkspacePacking,
reduceDependency,
} from './handlers';
import { BackstageNpmResolver } from './resolvers';
// All dependencies of the yarn plugin are bundled during the build. Chalk
@@ -44,8 +50,10 @@ if (!semverUtils.satisfiesWithPrereleases(YarnVersion, '^4.1.1')) {
/**
* @public
*/
const plugin: Plugin<Hooks & PackHooks> = {
const plugin: Plugin<Hooks & EssentialHooks & PackHooks> = {
hooks: {
afterWorkspaceDependencyAddition,
afterWorkspaceDependencyReplacement,
reduceDependency,
beforeWorkspacePacking,
},
+3 -2
View File
@@ -24036,7 +24036,7 @@ __metadata:
languageName: node
linkType: hard
"@yarnpkg/core@npm:^4.2.1, @yarnpkg/core@npm:^4.4.0":
"@yarnpkg/core@npm:^4.2.1, @yarnpkg/core@npm:^4.4.0, @yarnpkg/core@npm:^4.4.1":
version: 4.4.1
resolution: "@yarnpkg/core@npm:4.4.1"
dependencies:
@@ -50645,8 +50645,9 @@ __metadata:
"@backstage/cli-common": "workspace:^"
"@backstage/release-manifests": "workspace:^"
"@yarnpkg/builder": "npm:^4.2.1"
"@yarnpkg/core": "npm:^4.4.0"
"@yarnpkg/core": "npm:^4.4.1"
"@yarnpkg/fslib": "npm:^3.1.2"
"@yarnpkg/plugin-essentials": "npm:^4.4.0"
"@yarnpkg/plugin-npm": "patch:@yarnpkg/plugin-npm@npm%3A3.1.0#~/.yarn/patches/@yarnpkg-plugin-npm-npm-3.1.0-6533d0f5a1.patch"
"@yarnpkg/plugin-pack": "npm:^4.0.1"
fs-extra: "npm:^11.2.0"