From fd70d8661e8392c553373b52518329b9155fb649 Mon Sep 17 00:00:00 2001 From: MT Lewis Date: Tue, 7 Jan 2025 19:39:06 +0000 Subject: [PATCH] yarn-plugin: include both backstage: and npm: ranges in lockfile Signed-off-by: MT Lewis --- .changeset/wise-pillows-smile.md | 5 + packages/yarn-plugin/package.json | 1 + .../src/handlers/reduceDependency.test.ts | 35 +++-- .../src/handlers/reduceDependency.ts | 18 ++- packages/yarn-plugin/src/index.test.ts | 16 +- packages/yarn-plugin/src/index.ts | 2 + .../src/resolvers/BackstageNpmResolver.ts | 146 ++++++++++++++++++ packages/yarn-plugin/src/resolvers/index.ts | 16 ++ packages/yarn-plugin/src/util/index.ts | 1 + yarn.lock | 1 + 10 files changed, 216 insertions(+), 25 deletions(-) create mode 100644 .changeset/wise-pillows-smile.md create mode 100644 packages/yarn-plugin/src/resolvers/BackstageNpmResolver.ts create mode 100644 packages/yarn-plugin/src/resolvers/index.ts diff --git a/.changeset/wise-pillows-smile.md b/.changeset/wise-pillows-smile.md new file mode 100644 index 0000000000..febdeab2c4 --- /dev/null +++ b/.changeset/wise-pillows-smile.md @@ -0,0 +1,5 @@ +--- +'yarn-plugin-backstage': patch +--- + +Add both `npm:` and `backstage:` ranges to the lockfile to ensure compatibility with tools that parse the lockfile and ensure dependencies stay locked when building dist workspaces. diff --git a/packages/yarn-plugin/package.json b/packages/yarn-plugin/package.json index 5d8a402ae3..ee5e8336d2 100644 --- a/packages/yarn-plugin/package.json +++ b/packages/yarn-plugin/package.json @@ -34,6 +34,7 @@ "@backstage/release-manifests": "workspace:^", "@yarnpkg/core": "^4.0.3", "@yarnpkg/fslib": "^3.0.2", + "@yarnpkg/plugin-npm": "^3.0.1", "@yarnpkg/plugin-pack": "^4.0.0", "semver": "^7.6.0" }, diff --git a/packages/yarn-plugin/src/handlers/reduceDependency.test.ts b/packages/yarn-plugin/src/handlers/reduceDependency.test.ts index dfe29485b6..86b9037a74 100644 --- a/packages/yarn-plugin/src/handlers/reduceDependency.test.ts +++ b/packages/yarn-plugin/src/handlers/reduceDependency.test.ts @@ -107,7 +107,7 @@ describe('reduceDependency', () => { ), project, ), - ).rejects.toThrow(/unexpected version selector/i); + ).rejects.toThrow(/invalid backstage: version range/i); }, ); @@ -127,20 +127,31 @@ describe('reduceDependency', () => { ); }); - it('replaces the range with the corresponding npm package range', async () => { - await expect( - reduceDependency( - structUtils.makeDescriptor( - structUtils.makeIdent('backstage', 'core'), - 'backstage:^', - ), - project, - ), - ).resolves.toEqual( + it('adds the current Backstage version as a parameter on the range', async () => { + const result = await reduceDependency( structUtils.makeDescriptor( structUtils.makeIdent('backstage', 'core'), - 'npm:^6.7.8', + 'backstage:^', ), + project, + ); + + await expect( + structUtils.parseRange(result.range).params?.backstage, + ).toEqual('1.23.45'); + }); + + it('adds the appropriate npm package version based on the Backstage manifest as a parameter on the range', async () => { + const result = await reduceDependency( + structUtils.makeDescriptor( + structUtils.makeIdent('backstage', 'core'), + 'backstage:^', + ), + project, + ); + + await expect(structUtils.parseRange(result.range).params?.npm).toEqual( + '6.7.8', ); }); diff --git a/packages/yarn-plugin/src/handlers/reduceDependency.ts b/packages/yarn-plugin/src/handlers/reduceDependency.ts index e00a39c27c..8182256ad4 100644 --- a/packages/yarn-plugin/src/handlers/reduceDependency.ts +++ b/packages/yarn-plugin/src/handlers/reduceDependency.ts @@ -15,7 +15,7 @@ */ import { Descriptor, Project, structUtils } from '@yarnpkg/core'; -import { getPackageVersion } from '../util'; +import { getCurrentBackstageVersion, getPackageVersion } from '../util'; import { PROTOCOL } from '../constants'; export const reduceDependency = async ( @@ -24,12 +24,18 @@ export const reduceDependency = async ( ) => { const range = structUtils.parseRange(dependency.range); - if (range.protocol === PROTOCOL) { - return structUtils.makeDescriptor( - dependency, - `npm:^${await getPackageVersion(dependency, project.configuration)}`, + if (range.protocol !== PROTOCOL) { + return dependency; + } + + if (range.selector !== '^') { + throw new Error( + `Invalid backstage: version range found: ${dependency.range}`, ); } - return dependency; + return structUtils.bindDescriptor(dependency, { + backstage: getCurrentBackstageVersion(), + npm: await getPackageVersion(dependency, project.configuration), + }); }; diff --git a/packages/yarn-plugin/src/index.test.ts b/packages/yarn-plugin/src/index.test.ts index d720dc5909..75f120c157 100644 --- a/packages/yarn-plugin/src/index.test.ts +++ b/packages/yarn-plugin/src/index.test.ts @@ -67,10 +67,10 @@ const runYarnInstall = () => const nonWorkspaceEntries = (lockFileString: string = '') => { const lockFile = yaml.parse(lockFileString); - const result: Record = {}; + const result = []; for (const key of Object.keys(lockFile)) { if (lockFile[key].version !== '0.0.0-use.local') { - result[key] = lockFile[key]; + result.push(lockFile[key]); } } @@ -232,15 +232,17 @@ describe('Backstage yarn plugin', () => { 'utf-8', ); - const lockFile = yaml.parse(lockFileContent); + const descriptors = Object.keys(yaml.parse(lockFileContent)).flatMap( + entry => entry.split(', '), + ); // Versions from old manifest no longer appear in lockfile - expect(lockFile['@backstage/cli-common@npm:^0.1.1']).toBeUndefined(); - expect(lockFile['@backstage/config@npm:^0.1.2']).toBeUndefined(); + expect(descriptors).not.toContain('@backstage/cli-common@npm:^0.1.1'); + expect(descriptors).not.toContain('@backstage/config@npm:^0.1.2'); // Versions from new manifest have been added to lockfile - expect(lockFile['@backstage/cli-common@npm:^0.1.8']).toBeDefined(); - expect(lockFile['@backstage/config@npm:^1.0.0']).toBeDefined(); + expect(descriptors).toContain('@backstage/cli-common@npm:^0.1.8'); + expect(descriptors).toContain('@backstage/config@npm:^1.0.0'); }); describe('after removing backstage:^ dependencies', () => { diff --git a/packages/yarn-plugin/src/index.ts b/packages/yarn-plugin/src/index.ts index dcf2fe6943..ad18deb0ff 100644 --- a/packages/yarn-plugin/src/index.ts +++ b/packages/yarn-plugin/src/index.ts @@ -24,6 +24,7 @@ import { Plugin, Hooks, semverUtils, YarnVersion } from '@yarnpkg/core'; import { Hooks as PackHooks } from '@yarnpkg/plugin-pack'; import { beforeWorkspacePacking, reduceDependency } from './handlers'; +import { BackstageNpmResolver } from './resolvers'; // All dependencies of the yarn plugin are bundled during the build. Chalk // triples the size of the plugin bundle when included, so we avoid the @@ -48,6 +49,7 @@ const plugin: Plugin = { reduceDependency, beforeWorkspacePacking, }, + resolvers: [BackstageNpmResolver], }; export default plugin; diff --git a/packages/yarn-plugin/src/resolvers/BackstageNpmResolver.ts b/packages/yarn-plugin/src/resolvers/BackstageNpmResolver.ts new file mode 100644 index 0000000000..87bb057055 --- /dev/null +++ b/packages/yarn-plugin/src/resolvers/BackstageNpmResolver.ts @@ -0,0 +1,146 @@ +/* + * 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 { + structUtils, + Descriptor, + Locator, + Package, + Resolver, + ResolveOptions, +} from '@yarnpkg/core'; +import { NpmSemverResolver } from '@yarnpkg/plugin-npm'; +import { PROTOCOL } from '../constants'; + +export class BackstageNpmResolver implements Resolver { + static protocol = PROTOCOL; + + /** + * Target only descriptors using the `backstage:` protocol + */ + supportsDescriptor = (descriptor: Descriptor) => + descriptor.range.startsWith(BackstageNpmResolver.protocol); + + /** + * We treat any `backstage:` descriptor as if it's targeting the npm package + * version from the manifest for the current version of Backstage, by pulling + * in the `NpmSemverResolver` and deferring to its `getCandidates` method. + * + * The version itself comes from the `npm` parameter on the incoming + * descriptor, which is set by the `reduceDependency` hook. + */ + async getCandidates( + descriptor: Descriptor, + dependencies: Record, + opts: ResolveOptions, + ): Promise { + const npmVersion = structUtils.parseRange(descriptor.range).params?.npm; + + if (!npmVersion || Array.isArray(npmVersion)) { + throw new Error( + `Missing npm parameter on backstage: range "${descriptor.range}"`, + ); + } + + return new NpmSemverResolver().getCandidates( + structUtils.makeDescriptor(descriptor, `npm:^${npmVersion}`), + dependencies, + opts, + ); + } + + /** + * We insert the `npm:^` descriptor as an additional dependency to + * ensure that dependencies remain locked when adding and removing the plugin + * from repositories. This is relevant for example when building packed + * production-like workspaces for testing using `backstage-cli + * build-workspace`. + */ + getResolutionDependencies( + descriptor: Descriptor, + ): Record { + const npmVersion = structUtils.parseRange(descriptor.range).params?.npm; + + if (!npmVersion) { + throw new Error(`Unreachable`); + } + + return { + [structUtils.stringifyIdent(descriptor)]: structUtils.makeDescriptor( + descriptor, + `npm:^${npmVersion}`, + ), + }; + } + + /** + * This method is called when deduplicating locators. We first convert any + * `backstage:` locators into the corresponding `npm:` locator, and then defer + * to the implementation from `NpmSemverResolver`. + */ + async getSatisfying( + descriptor: Descriptor, + dependencies: Record, + locators: Array, + opts: ResolveOptions, + ) { + let npmDescriptor = descriptor; + const range = structUtils.parseRange(npmDescriptor.range); + + if (range.protocol === PROTOCOL) { + const npmVersion = range.params?.npm; + npmDescriptor = structUtils.makeDescriptor( + descriptor, + `npm:^${npmVersion}`, + ); + } + + return new NpmSemverResolver().getSatisfying( + npmDescriptor, + dependencies, + locators, + opts, + ); + } + + /** + * Stub - no descriptor binding is needed in this resolver (note though that + * it does rely on the binding performed in the `reduceDependency` hook). + */ + bindDescriptor = (descriptor: Descriptor) => descriptor; + + /** + * This plugin does not need to support any locators itself, since the + * `getCandidates` method will always convert `backstage:` versions into + * `npm:` versions which are resolved using the `NpmSemverResolver`. + */ + supportsLocator = () => false; + + /** + * This method should never be called, since all emitted locators use the + * `npm:` protocol. + */ + shouldPersistResolution = () => { + throw new Error('Unreachable'); + }; + + /** + * This method should never be called, since all emitted locators use the + * `npm:` protocol. + */ + resolve = async () => { + throw new Error(`Unreachable`); + }; +} diff --git a/packages/yarn-plugin/src/resolvers/index.ts b/packages/yarn-plugin/src/resolvers/index.ts new file mode 100644 index 0000000000..3452967e80 --- /dev/null +++ b/packages/yarn-plugin/src/resolvers/index.ts @@ -0,0 +1,16 @@ +/* + * 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. + */ +export { BackstageNpmResolver } from './BackstageNpmResolver'; diff --git a/packages/yarn-plugin/src/util/index.ts b/packages/yarn-plugin/src/util/index.ts index 139c033468..fd8bc397d6 100644 --- a/packages/yarn-plugin/src/util/index.ts +++ b/packages/yarn-plugin/src/util/index.ts @@ -14,4 +14,5 @@ * limitations under the License. */ +export { getCurrentBackstageVersion } from './getCurrentBackstageVersion'; export { getPackageVersion } from './getPackageVersion'; diff --git a/yarn.lock b/yarn.lock index 894dee3f83..7502e9bf5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48448,6 +48448,7 @@ __metadata: "@yarnpkg/builder": "npm:^4.0.0" "@yarnpkg/core": "npm:^4.0.3" "@yarnpkg/fslib": "npm:^3.0.2" + "@yarnpkg/plugin-npm": "npm:^3.0.1" "@yarnpkg/plugin-pack": "npm:^4.0.0" fs-extra: "npm:^11.2.0" nodemon: "npm:^3.0.1"