yarn-plugin: rewrite using reduceDependency hook

Switching to using the reduceDependency hook
simplifies the plugin, since we can do all the
work in one go. It also means that the yarn.lock
file doesn't need to change when adding and
removing the yarn plugin, which avoids
unintentionally unlocking packages versions.

As part of this change, the descriptor emitted by
the plugin has been changed from
`npm:<manifest-version>` to
`npm:^<manifest-version>`, to match the range
emitted when packing packages. This ensures good
interoperability with the `backstage-cli
build-workspace`, which packs packages into a new
workspace and copies yarn.lock across.

Signed-off-by: MT Lewis <mtlewis@users.noreply.github.com>
This commit is contained in:
MT Lewis
2024-12-12 20:33:58 +00:00
parent 4ea9a07726
commit ac91864de3
12 changed files with 288 additions and 465 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'yarn-plugin-backstage': patch
---
Switch to using `reduceDependency` hook to replace `backstage:^` versions. This
makes the same yarn.lock file valid whether or not the plugin is installed.
+1
View File
@@ -41,6 +41,7 @@
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@yarnpkg/builder": "^4.0.0",
"fs-extra": "^11.2.0",
"nodemon": "^3.0.1"
}
}
+3 -2
View File
@@ -3,10 +3,11 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Hooks } from '@yarnpkg/plugin-pack';
import { Hooks } from '@yarnpkg/core';
import { Hooks as Hooks_2 } from '@yarnpkg/plugin-pack';
import { Plugin as Plugin_2 } from '@yarnpkg/core';
// @public (undocumented)
const plugin: Plugin_2<Hooks>;
const plugin: Plugin_2<Hooks & Hooks_2>;
export default plugin;
```
@@ -15,11 +15,7 @@
*/
import { Descriptor, Workspace, structUtils } from '@yarnpkg/core';
import {
bindBackstageVersion,
getCurrentBackstageVersion,
getPackageVersion,
} from '../util';
import { getPackageVersion } from '../util';
import { PROTOCOL } from '../constants';
const hasBackstageVersion = (range: string) =>
@@ -45,8 +41,6 @@ export const beforeWorkspacePacking = async (
workspace: Workspace,
rawManifest: any,
) => {
const backstageVersion = getCurrentBackstageVersion();
for (const dependencyType of ['dependencies', 'devDependencies'] as const) {
const entries = Array.from(
workspace.manifest.getForScope(dependencyType).values(),
@@ -69,7 +63,7 @@ export const beforeWorkspacePacking = async (
);
rawManifest[finalDependencyType][ident] = `^${await getPackageVersion(
bindBackstageVersion(descriptor, backstageVersion),
descriptor,
workspace.project.configuration,
)}`;
}
@@ -0,0 +1,18 @@
/*
* 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.
*/
export { beforeWorkspacePacking } from './beforeWorkspacePacking';
export { reduceDependency } from './reduceDependency';
@@ -0,0 +1,209 @@
/*
* 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 { join as joinPath } from 'path';
import fs from 'fs-extra';
import { Configuration, Project, structUtils, httpUtils } from '@yarnpkg/core';
import { npath, ppath } from '@yarnpkg/fslib';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { reduceDependency } from './reduceDependency';
describe('reduceDependency', () => {
const mockDir = createMockDirectory();
let project: Project;
let getMock: jest.SpyInstance<
ReturnType<(typeof httpUtils)['get']>,
Parameters<(typeof httpUtils)['get']>
>;
beforeEach(() => {
project = new Project(ppath.cwd(), {
configuration: Configuration.create(ppath.cwd()),
});
jest
.spyOn(ppath, 'cwd')
.mockReturnValue(npath.toPortablePath(mockDir.path));
jest
.spyOn(process, 'cwd')
.mockReturnValue(npath.toPortablePath(mockDir.path));
getMock = jest.spyOn(httpUtils, 'get').mockResolvedValue({
releaseVersion: '1.23.45',
packages: [
{
name: '@backstage/core',
version: '6.7.8',
},
],
});
mockDir.setContent({
'backstage.json': JSON.stringify({
version: '1.23.45',
}),
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
}),
packages: {
a: {
'package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': 'backstage:^',
},
}),
},
},
});
});
afterEach(() => {
jest.restoreAllMocks();
});
it.each`
range
${'npm:1.2.3'}
${'link:../foo/bar'}
${'workspace:^'}
`('returns non-backstage range "$range" unchanged', async ({ range }) => {
const descriptor = structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
range,
);
await expect(reduceDependency(descriptor, project)).resolves.toEqual(
descriptor,
);
});
it.each`
selector
${'*'}
${'latest'}
`(
'rejects backstage: ranges with invalid selector "$selector"',
async ({ selector }) => {
await expect(
reduceDependency(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
`backstage:${selector}`,
),
project,
),
).rejects.toThrow(/unexpected version selector/i);
},
);
describe('with range "backstage:^"', () => {
it('loads the manifest for the current Backstage version', async () => {
await reduceDependency(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^',
),
project,
);
expect(getMock).toHaveBeenCalledWith(
'https://versions.backstage.io/v1/releases/1.23.45/manifest.json',
expect.anything(),
);
});
it('replaces the range with the corresponding npm package range', async () => {
await expect(
reduceDependency(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^',
),
project,
),
).resolves.toEqual(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'npm:^6.7.8',
),
);
});
it('throws if backstage.json is missing', async () => {
await fs.remove(joinPath(mockDir.path, 'backstage.json'));
await expect(
reduceDependency(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'other'),
'backstage:^',
),
project,
),
).rejects.toThrow(
expect.objectContaining({
message: expect.stringContaining(
'Valid version string not found in backstage.json',
),
}),
);
});
it(`throws if backstage.json doesn't contain a valid version`, async () => {
mockDir.addContent({
'backstage.json': JSON.stringify({}),
});
await expect(
reduceDependency(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'other'),
'backstage:^',
),
project,
),
).rejects.toThrow(
expect.objectContaining({
message: expect.stringContaining(
'Valid version string not found in backstage.json',
),
}),
);
});
it(`throws for packages that don't appear in the manifest`, async () => {
await expect(
reduceDependency(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'other'),
'backstage:^',
),
project,
),
).rejects.toThrow(
expect.objectContaining({
message: expect.stringContaining(
'Package @backstage/other not found in manifest for Backstage v1.23.45',
),
}),
);
});
});
});
@@ -0,0 +1,35 @@
/*
* 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 { Descriptor, Project, structUtils } from '@yarnpkg/core';
import { getPackageVersion } from '../util';
import { PROTOCOL } from '../constants';
export const reduceDependency = async (
dependency: Descriptor,
project: Project,
) => {
const range = structUtils.parseRange(dependency.range);
if (range.protocol === PROTOCOL) {
return structUtils.makeDescriptor(
dependency,
`npm:^${await getPackageVersion(dependency, project.configuration)}`,
);
}
return dependency;
};
+5 -6
View File
@@ -21,10 +21,9 @@
* @packageDocumentation
*/
import { Plugin, semverUtils, YarnVersion } from '@yarnpkg/core';
import { Hooks } from '@yarnpkg/plugin-pack';
import { beforeWorkspacePacking } from './handlers/beforeWorkspacePacking';
import { BackstageResolver } from './resolver/BackstageResolver';
import { Plugin, Hooks, semverUtils, YarnVersion } from '@yarnpkg/core';
import { Hooks as PackHooks } from '@yarnpkg/plugin-pack';
import { beforeWorkspacePacking, reduceDependency } from './handlers';
// 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
@@ -44,11 +43,11 @@ if (!semverUtils.satisfiesWithPrereleases(YarnVersion, '^4.1.1')) {
/**
* @public
*/
const plugin: Plugin<Hooks> = {
const plugin: Plugin<Hooks & PackHooks> = {
hooks: {
reduceDependency,
beforeWorkspacePacking,
},
resolvers: [BackstageResolver],
};
export default plugin;
@@ -1,301 +0,0 @@
/*
* 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,
Project,
ResolveOptions,
structUtils,
ThrowReport,
httpUtils,
} from '@yarnpkg/core';
import { npath, ppath } from '@yarnpkg/fslib';
import { BackstageResolver } from './BackstageResolver';
import { createMockDirectory } from '@backstage/backend-test-utils';
describe('BackstageResolver', () => {
const mockDir = createMockDirectory();
let backstageResolver: BackstageResolver;
let resolveOptions: ResolveOptions;
beforeEach(() => {
jest
.spyOn(ppath, 'cwd')
.mockReturnValue(npath.toPortablePath(mockDir.path));
jest
.spyOn(process, 'cwd')
.mockReturnValue(npath.toPortablePath(mockDir.path));
jest.spyOn(httpUtils, 'get').mockResolvedValue({
releaseVersion: '1.23.45',
packages: [
{
name: '@backstage/core',
version: '6.7.8',
},
],
});
mockDir.setContent({
'backstage.json': JSON.stringify({
version: '1.23.45',
}),
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
}),
packages: {
a: {
'package.json': JSON.stringify({
name: 'a',
dependencies: {
'@backstage/core': 'backstage:^',
},
}),
},
},
});
backstageResolver = new BackstageResolver();
resolveOptions = {
resolver: backstageResolver,
project: new Project(ppath.cwd(), {
configuration: Configuration.create(ppath.cwd()),
}),
report: new ThrowReport(),
};
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('supportsDescriptor', () => {
it.each([
['backstage:^'],
['backstage:1.26.0-next.3'],
['backstage:anything'],
])('returns true for range "%s"', range => {
expect(
backstageResolver.supportsDescriptor(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
range,
),
),
).toEqual(true);
});
});
describe('bindDescriptor', () => {
describe('with range "backstage:^"', () => {
it('returns a descriptor with a version range for the current Backstage version', () => {
expect(
backstageResolver.bindDescriptor(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^',
),
),
).toEqual(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^::v=1.23.45',
),
);
});
});
describe('with range "backstage:^::v=1.23.45"', () => {
it('returns the correct descriptor', () => {
expect(
backstageResolver.bindDescriptor(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^::v=1.23.45',
),
),
).toEqual(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^::v=1.23.45',
),
);
});
});
});
describe('getCandidates', () => {
it('returns an npm: descriptor based on the manifest for the appropriate backstage version', async () => {
const descriptor = structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^::v=1.23.45',
);
await expect(
backstageResolver.getCandidates(descriptor, {}, resolveOptions),
).resolves.toEqual([structUtils.makeLocator(descriptor, 'npm:6.7.8')]);
});
it('rejects descriptors not using the backstage: protocol', async () => {
await expect(
backstageResolver.getCandidates(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'npm:1.2.3',
),
{},
resolveOptions,
),
).rejects.toThrow(/unsupported version protocol/i);
});
it('rejects backstage: ranges missing a version parameter', async () => {
await expect(
backstageResolver.getCandidates(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^',
),
{},
resolveOptions,
),
).rejects.toThrow(/missing Backstage version/i);
});
it('rejects backstage: ranges with multiple version parameters', async () => {
await expect(
backstageResolver.getCandidates(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^::v=1&v=2',
),
{},
resolveOptions,
),
).rejects.toThrow(/multiple Backstage versions/i);
});
it.each`
selector
${'*'}
${'latest'}
`(
'rejects backstage: ranges with invalid selector "$selector"',
async ({ selector }) => {
await expect(
backstageResolver.getCandidates(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
`backstage:${selector}`,
),
{},
resolveOptions,
),
).rejects.toThrow(/unexpected version selector/i);
},
);
});
describe('getSatisfying', () => {
it('filters out locators for other packages', async () => {
await expect(
backstageResolver.getSatisfying(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^::v=1.23.45',
),
{},
[
structUtils.makeLocator(
structUtils.makeIdent('backstage', 'foo'),
'npm:1.2.3',
),
structUtils.makeLocator(
structUtils.makeIdent('backstage', 'core'),
'npm:6.7.8',
),
structUtils.makeLocator(
structUtils.makeIdent('backstage', 'bar'),
'npm:1.2.3',
),
],
resolveOptions,
),
).resolves.toEqual({
locators: [
structUtils.makeLocator(
structUtils.makeIdent('backstage', 'core'),
'npm:6.7.8',
),
],
sorted: true,
});
});
it('filters out locators for other package versions', async () => {
await expect(
backstageResolver.getSatisfying(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^::v=1.23.45',
),
{},
[
structUtils.makeLocator(
structUtils.makeIdent('backstage', 'core'),
'npm:5.6.7',
),
structUtils.makeLocator(
structUtils.makeIdent('backstage', 'core'),
'npm:6.7.8',
),
structUtils.makeLocator(
structUtils.makeIdent('backstage', 'bar'),
'npm:7.8.9',
),
],
resolveOptions,
),
).resolves.toEqual({
locators: [
structUtils.makeLocator(
structUtils.makeIdent('backstage', 'core'),
'npm:6.7.8',
),
],
sorted: true,
});
});
it('throws for non `backstage:` descriptors', async () => {
await expect(
backstageResolver.getSatisfying(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'npm:1.2.3',
),
{},
[],
resolveOptions,
),
).rejects.toThrow(/unsupported version protocol/i);
});
});
});
@@ -1,128 +0,0 @@
/*
* 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 { PROTOCOL } from '../constants';
import {
bindBackstageVersion,
getCurrentBackstageVersion,
getPackageVersion,
} from '../util';
export class BackstageResolver implements Resolver {
static protocol = PROTOCOL;
supportsDescriptor = (descriptor: Descriptor) =>
descriptor.range.startsWith(BackstageResolver.protocol);
shouldPersistResolution = () => true;
/**
* Called for each dependency present in the dependency list of a package
* definition. If it returns a new descriptor, this new descriptor will be
* used.
*
* In this plugin, we convert the specific range "backstage:^" to
* "backstage:<version from backstage.json>". This new range will be the one
* stored in lockfile entries, ensuring that we re-resolve the package when
* the version in backstage.json changes.
*/
bindDescriptor(descriptor: Descriptor): Descriptor {
if (descriptor.range === 'backstage:^') {
return bindBackstageVersion(descriptor, getCurrentBackstageVersion());
}
return descriptor;
}
/**
* Given a descriptor, return the list of locators that potentially satisfy
* it. The implementation in this plugin converts a `backstage:` range with a
* concrete version into the appropriate concrete npm version for that
* backstage release.
*/
async getCandidates(
descriptor: Descriptor,
_dependencies: Record<string, Package>,
opts: ResolveOptions,
): Promise<Locator[]> {
return [
structUtils.makeLocator(
descriptor,
`npm:${await getPackageVersion(
descriptor,
opts.project.configuration,
)}`,
),
];
}
/**
* Given a descriptor and a list of possible locators, return a filtered list
* containing only locators that satisfy the descriptor. Since each Backstage
* release version corresponds to a single version for each package, we just
* need to filter that array for locators with that exact version.
*/
async getSatisfying(
descriptor: Descriptor,
_dependencies: Record<string, Package>,
locators: Array<Locator>,
opts: ResolveOptions,
): Promise<{ locators: Locator[]; sorted: boolean }> {
const packageVersion = await getPackageVersion(
descriptor,
opts.project.configuration,
);
return {
locators: locators.filter(
locator =>
structUtils.areIdentsEqual(descriptor, locator) &&
locator.reference === `npm:${packageVersion}`,
),
sorted: true,
};
}
/**
* This plugin does not need to support any locators itself, since the
* `getCandidates` method will always convert `backstage:` versions into
* `npm:` versions which can be handled as usual.
*/
supportsLocator = () => false;
/**
* This resolver never transforms the packages that are actually depended on,
* only replaces versions. As such there's never a need to add additional
* dependencies.
*/
getResolutionDependencies = () => ({});
/**
* Once transformed into locators (through getCandidates), the versions are
* resolved by the NpmSemverResolver
*/
async resolve(): Promise<Package> {
throw new Error(`Unreachable`);
}
}
+8 -20
View File
@@ -31,6 +31,11 @@ export const getCurrentBackstageVersion = () => {
const workspaceRoot = npath.toPortablePath(
findPaths(npath.fromPortablePath(ppath.cwd())).targetRoot,
);
const backstageJsonPath = ppath.join(workspaceRoot, BACKSTAGE_JSON);
if (!xfs.existsSync(backstageJsonPath)) {
throw new Error('Valid version string not found in backstage.json');
}
const backstageJson = xfs.readJsonSync(
ppath.join(workspaceRoot, BACKSTAGE_JSON),
@@ -45,13 +50,6 @@ export const getCurrentBackstageVersion = () => {
return backstageVersion;
};
export const bindBackstageVersion = (
descriptor: Descriptor,
backstageVersion: string,
) => {
return structUtils.bindDescriptor(descriptor, { v: backstageVersion });
};
export const getPackageVersion = async (
descriptor: Descriptor,
configuration: Configuration,
@@ -71,20 +69,10 @@ export const getPackageVersion = async (
);
}
if (!range.params?.v) {
throw new Error(
`Missing Backstage version parameter in range "${descriptor.range}" for package ${ident}`,
);
}
if (Array.isArray(range.params.v)) {
throw new Error(
`Multiple Backstage versions specified in range "${descriptor.range}" for package ${ident}`,
);
}
const backstageVersion = getCurrentBackstageVersion();
const manifest = await getManifestByVersion({
version: range.params.v,
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:
@@ -123,7 +111,7 @@ export const getPackageVersion = async (
if (!manifestEntry) {
throw new Error(
`Package ${ident} not found in manifest for Backstage v${range.selector}. ` +
`Package ${ident} not found in manifest for Backstage v${backstageVersion}. ` +
`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 ` +
+1
View File
@@ -48206,6 +48206,7 @@ __metadata:
"@yarnpkg/core": ^4.0.3
"@yarnpkg/fslib": ^3.0.2
"@yarnpkg/plugin-pack": ^4.0.0
fs-extra: ^11.2.0
nodemon: ^3.0.1
semver: ^7.6.0
languageName: unknown