Merge pull request #24136 from backstage/yarn-plugin

Add `yarn-plugin-backstage`
This commit is contained in:
Patrik Oldsberg
2024-06-05 09:58:46 +02:00
committed by GitHub
14 changed files with 1825 additions and 44 deletions
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+2
View File
@@ -0,0 +1,2 @@
# @yarnpkg/builder build output
bundles/
+13
View File
@@ -0,0 +1,13 @@
# yarn-plugin-backstage
This yarn plugin adds a `backstage:` version protocol to yarn, which replaces
specific version ranges for `@backstage/` packages. The only version range
supported by this plugin is `backstage:^`, which has similar semantics to the
corresponding [`workspace` range](https://yarnpkg.com/features/workspaces#cross-references) when using
workspace dependencies; locally, the package will always resolve to the exact
version specified in the manifest for the Backstage release listed in
backstage.json. If the dependent package is published, this version will be
prefixed by `^`.
**This plugin is still under active development, and requires some further
testing before we recommend it for general use.**
+11
View File
@@ -0,0 +1,11 @@
## API Report File for "yarn-plugin-backstage"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Plugin as Plugin_2 } from '@yarnpkg/core';
// @public (undocumented)
const plugin: Plugin_2;
export default plugin;
```
+10
View File
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: yarn-plugin-backstage
title: yarn-plugin-backstage
description: Yarn plugin for working with Backstage monorepos
spec:
lifecycle: experimental
type: backstage-node-library
owner: maintainers
+45
View File
@@ -0,0 +1,45 @@
{
"name": "yarn-plugin-backstage",
"version": "0.0.0",
"description": "Yarn plugin for working with Backstage monorepos",
"backstage": {
"role": "node-library"
},
"private": true,
"keywords": [
"backstage"
],
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/yarn-plugin"
},
"license": "Apache-2.0",
"main": "./src/index.ts",
"scripts": {
"build": "builder build plugin",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"start": "nodemon --",
"test": "backstage-cli package test"
},
"nodemonConfig": {
"exec": "builder build plugin",
"ext": "ts",
"watch": "./src"
},
"dependencies": {
"@backstage/cli-common": "workspace:^",
"@backstage/release-manifests": "workspace:^",
"@yarnpkg/core": "^4.0.3",
"@yarnpkg/fslib": "^3.0.2",
"semver": "^7.6.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@yarnpkg/builder": "^4.0.0",
"nodemon": "^3.0.1"
}
}
+17
View File
@@ -0,0 +1,17 @@
/*
* 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 const PROTOCOL = 'backstage:';
@@ -0,0 +1,124 @@
/*
* 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 { Manifest, Workspace } from '@yarnpkg/core';
import { npath, ppath } from '@yarnpkg/fslib';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { beforeWorkspacePacking } from './beforeWorkspacePacking';
jest.mock('@backstage/release-manifests', () => ({
getManifestByVersion: jest.fn().mockResolvedValue({
releaseVersion: '1.23.45',
packages: [
{
name: '@backstage/core',
version: '3.2.1',
},
],
}),
}));
const makeWorkspace = (manifest: object) => {
return {
manifest: Manifest.fromText(JSON.stringify(manifest)),
} as Workspace;
};
describe('beforeWorkspacePacking', () => {
const mockDir = createMockDirectory();
beforeEach(() => {
jest
.spyOn(ppath, 'cwd')
.mockReturnValue(npath.toPortablePath(mockDir.path));
jest
.spyOn(process, 'cwd')
.mockReturnValue(npath.toPortablePath(mockDir.path));
mockDir.setContent({
'backstage.json': JSON.stringify({
version: '1.23.45',
}),
'package.json': JSON.stringify({
workspaces: {
packages: ['packages/*'],
},
}),
});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each`
dependencyType
${'dependencies'}
${'devDependencies'}
${'optionalDependencies'}
`('$dependencyType', ({ dependencyType }) => {
it(`ignores ${dependencyType} that don't use the backstage: protocol`, () => {
const result = {
name: 'test-package',
[dependencyType]: {
foo: '^1.1.1',
},
};
beforeWorkspacePacking(makeWorkspace(result), result);
expect(result).toEqual({
name: 'test-package',
[dependencyType]: {
foo: '^1.1.1',
},
});
});
it(`throws an error for any backstage: versions with a selector other than ^`, async () => {
const result = {
name: 'test-package',
[dependencyType]: {
'@backstage/core': 'backstage:^1.1.1',
},
};
await expect(() =>
beforeWorkspacePacking(makeWorkspace(result), result),
).rejects.toThrow();
});
it('converts backstage:^ versions to the corresponding package version prefixed by ^', async () => {
const result = {
name: 'test-package',
[dependencyType]: {
'@backstage/core': 'backstage:^',
},
};
await beforeWorkspacePacking(makeWorkspace(result), result);
expect(result).toEqual({
name: 'test-package',
[dependencyType]: {
'@backstage/core': '^3.2.1',
},
});
});
});
});
@@ -0,0 +1,72 @@
/*
* 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, Workspace, structUtils } from '@yarnpkg/core';
import { getCurrentBackstageVersion, getPackageVersion } from '../util';
import { PROTOCOL } from '../constants';
const getFinalDependencyType = (
dependencyType: string,
descriptor: Descriptor,
workspace: Workspace,
) => {
if (dependencyType !== 'dependencies') {
return dependencyType;
}
return workspace.manifest.ensureDependencyMeta(
structUtils.makeDescriptor(descriptor, 'unknown'),
).optional
? 'optionalDependencies'
: dependencyType;
};
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(),
).filter(descriptor => descriptor.range.startsWith(PROTOCOL));
for (const descriptor of entries) {
const ident = structUtils.stringifyIdent(descriptor);
const range = structUtils.parseRange(descriptor.range);
if (range.selector !== '^') {
throw new Error(
`Unexpected version range "${descriptor.range}" for dependency on "${ident}"`,
);
}
const finalDependencyType = getFinalDependencyType(
dependencyType,
descriptor,
workspace,
);
rawManifest[finalDependencyType][ident] = `^${await getPackageVersion(
structUtils.makeDescriptor(
descriptor,
`${PROTOCOL}${backstageVersion}`,
),
)}`;
}
}
};
+38
View File
@@ -0,0 +1,38 @@
/*
* 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.
*/
/**
* Yarn plugin for resolving package versions based on
* a Backstage version manifest.
*
* @packageDocumentation
*/
import { Plugin } from '@yarnpkg/core';
import { beforeWorkspacePacking } from './handlers/beforeWorkspacePacking';
import { BackstageResolver } from './resolver/BackstageResolver';
/**
* @public
*/
const plugin: Plugin = {
hooks: {
beforeWorkspacePacking,
},
resolvers: [BackstageResolver],
};
export default plugin;
@@ -0,0 +1,181 @@
/*
* 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 } from '@yarnpkg/core';
import { npath, ppath } from '@yarnpkg/fslib';
import { BackstageResolver } from './BackstageResolver';
import { createMockDirectory } from '@backstage/backend-test-utils';
jest.mock('@backstage/release-manifests', () => ({
getManifestByVersion: jest.fn().mockResolvedValue({
releaseVersion: '1.23.45',
packages: [
{
name: '@backstage/core',
version: '6.7.8',
},
],
}),
}));
describe('BackstageResolver', () => {
const mockDir = createMockDirectory();
let backstageResolver: BackstageResolver;
beforeEach(() => {
jest
.spyOn(ppath, 'cwd')
.mockReturnValue(npath.toPortablePath(mockDir.path));
jest
.spyOn(process, 'cwd')
.mockReturnValue(npath.toPortablePath(mockDir.path));
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();
});
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 basedwith 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:1.23.45',
),
);
});
});
describe('with range "backstage:1.23.45"', () => {
it('throws an error', () => {
expect(() =>
backstageResolver.bindDescriptor(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:1.23.45',
),
),
).toThrow(/unsupported version range/i);
});
});
});
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:1.23.45',
);
await expect(
backstageResolver.getCandidates(descriptor),
).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',
),
),
).rejects.toThrow(/unsupported version protocol/i);
});
it('rejects backstage: ranges with a ^ shorthand version', async () => {
await expect(
backstageResolver.getCandidates(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:^',
),
),
).rejects.toThrow(/invalid backstage version/i);
});
it('rejects backstage: ranges with a * shorthand version', async () => {
await expect(
backstageResolver.getCandidates(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:*',
),
),
).rejects.toThrow(/invalid backstage version/i);
});
it('rejects backstage: ranges with an invalid version specified', async () => {
await expect(
backstageResolver.getCandidates(
structUtils.makeDescriptor(
structUtils.makeIdent('backstage', 'core'),
'backstage:latest',
),
),
).rejects.toThrow(/invalid backstage version/i);
});
});
});
@@ -0,0 +1,124 @@
/*
* 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,
} from '@yarnpkg/core';
import semver from 'semver';
import { PROTOCOL } from '../constants';
import { 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:^') {
throw new Error(
`Unsupported version range "${
descriptor.range
}" for package ${structUtils.stringifyIdent(
descriptor,
)}. The backstage protocol only supports the range "backstage:^".`,
);
}
return structUtils.makeDescriptor(
descriptor,
`${PROTOCOL}${getCurrentBackstageVersion()}`,
);
}
/**
* 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): Promise<Locator[]> {
const range = structUtils.parseRange(descriptor.range);
if (range.protocol !== BackstageResolver.protocol) {
throw new Error(
`Unsupported version protocol in version range "${
descriptor.range
}" for package ${structUtils.stringifyIdent(descriptor)}`,
);
}
if (!semver.valid(range.selector)) {
throw new Error(
`Invalid Backstage version string when resolving version for ${structUtils.stringifyIdent(
descriptor,
)}`,
);
}
return [
structUtils.makeLocator(
descriptor,
`npm:${await getPackageVersion(descriptor)}`,
),
];
}
/**
* 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 = () => ({});
/**
* Candidate versions produced by this resolver always use the `npm:`
* protocol, so this function will never be called.
*/
async getSatisfying(): Promise<{ locators: Locator[]; sorted: boolean }> {
throw new Error('Unreachable');
}
/**
* Once transformed into locators (through getCandidates), the versions are
* resolved by the NpmSemverResolver
*/
async resolve(): Promise<Package> {
throw new Error(`Unreachable`);
}
}
+72
View File
@@ -0,0 +1,72 @@
/*
* 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 { ppath, xfs } from '@yarnpkg/fslib';
import { valid as semverValid } from 'semver';
import { getManifestByVersion } from '@backstage/release-manifests';
import { BACKSTAGE_JSON, findPaths } from '@backstage/cli-common';
import { Descriptor, structUtils } from '@yarnpkg/core';
import { PROTOCOL } from './constants';
export const getCurrentBackstageVersion = () => {
const workspaceRoot = ppath.resolve(findPaths(ppath.cwd()).targetRoot);
const backstageJson = xfs.readJsonSync(
ppath.join(workspaceRoot, BACKSTAGE_JSON),
);
const backstageVersion = semverValid(backstageJson.version);
if (backstageVersion === null) {
throw new Error('Valid version string not found in backstage.json');
}
return backstageVersion;
};
export const getPackageVersion = async (descriptor: Descriptor) => {
const ident = structUtils.stringifyIdent(descriptor);
const range = structUtils.parseRange(descriptor.range);
if (range.protocol !== PROTOCOL) {
throw new Error(`Unexpected ${range.protocol} range when packing`);
}
if (!semverValid(range.selector)) {
throw new Error(`Missing backstage version in range ${descriptor.range}`);
}
const manifest = await getManifestByVersion({
version: range.selector,
});
const manifestEntry = manifest.packages.find(
candidate => candidate.name === ident,
);
if (!manifestEntry) {
throw new Error(
`Package ${ident} not found in manifest for Backstage v${range.selector}. ` +
`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.`,
);
}
return manifestEntry.version;
};
+1115 -44
View File
File diff suppressed because it is too large Load Diff