diff --git a/.changeset/two-islands-deny.md b/.changeset/two-islands-deny.md new file mode 100644 index 0000000000..1ee2020b5e --- /dev/null +++ b/.changeset/two-islands-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Add support for `backstage:^` version ranges to versions:bump when using the experimental yarn plugin diff --git a/packages/cli/package.json b/packages/cli/package.json index 99eba89987..d8f17b1aa7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -117,6 +117,7 @@ "jest-css-modules": "^2.1.0", "jest-environment-jsdom": "^29.0.2", "jest-runtime": "^29.0.2", + "js-yaml": "^4.1.0", "json-schema": "^0.4.0", "lodash": "^4.17.21", "mini-css-extract-plugin": "^2.4.2", diff --git a/packages/cli/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 5b981d3890..235bd30435 100644 --- a/packages/cli/src/commands/versions/bump.ts +++ b/packages/cli/src/commands/versions/bump.ts @@ -25,6 +25,8 @@ import chalk from 'chalk'; import ora from 'ora'; import semver from 'semver'; import { OptionValues } from 'commander'; +import yaml from 'js-yaml'; +import z from 'zod'; import { isError, NotFoundError } from '@backstage/errors'; import { resolve as resolvePath } from 'path'; import { run } from '../../lib/run'; @@ -76,6 +78,14 @@ type PkgVersionInfo = { export default async (opts: OptionValues) => { const lockfilePath = paths.resolveTargetRoot('yarn.lock'); const lockfile = await Lockfile.load(lockfilePath); + const hasYarnPlugin = await getHasYarnPlugin(); + + if (hasYarnPlugin) { + console.log( + `Backstage yarn plugin detected, will use backstage: version ranges where possible...`, + ); + } + let pattern = opts.pattern; if (!pattern) { @@ -177,12 +187,27 @@ export default async (opts: OptionValues) => { for (const depType of DEP_TYPES) { if (depType in pkgJson && dep.name in pkgJson[depType]) { const oldRange = pkgJson[depType][dep.name]; - pkgJson[depType][dep.name] = dep.range; + + // backstage:^ are written to the lockfile as + // backstage:, so that updates to + // backstage.json can be detected during yarn install. In order to + // locate the corresponding lockfile entry for "backstage:^" + // versions, we need to perform the same transformation. + const oldLockfileRange = await asLockfileVersion(oldRange); + + // Don't use backstage:^ versions for peerDependencies; they only + // support npm and workspace: versions. + const useBackstageRange = + hasYarnPlugin && depType !== 'peerDependencies'; + + const newRange = useBackstageRange ? 'backstage:^' : dep.range; + + pkgJson[depType][dep.name] = newRange; // Check if the update was at least a pre-v1 minor or post-v1 major release const lockfileEntry = lockfile .get(dep.name) - ?.find(entry => entry.range === oldRange); + ?.find(entry => entry.range === oldLockfileRange); if (lockfileEntry) { const from = lockfileEntry.version; const to = dep.target; @@ -354,16 +379,23 @@ export function createVersionFinder(options: { }; } -export async function bumpBackstageJsonVersion(version: string) { - const backstageJsonPath = paths.resolveTargetRoot(BACKSTAGE_JSON); - const backstageJson = await fs.readJSON(backstageJsonPath).catch(e => { +function getBackstageJsonPath() { + return paths.resolveTargetRoot(BACKSTAGE_JSON); +} + +async function getBackstageJson() { + const backstageJsonPath = getBackstageJsonPath(); + return fs.readJSON(backstageJsonPath).catch(e => { if (e.code === 'ENOENT') { // gracefully continue in case the file doesn't exist return; } throw e; }); +} +export async function bumpBackstageJsonVersion(version: string) { + const backstageJson = await getBackstageJson(); const prevVersion = backstageJson?.version; if (prevVersion === version) { @@ -394,7 +426,7 @@ export async function bumpBackstageJsonVersion(version: string) { } await fs.writeJson( - backstageJsonPath, + getBackstageJsonPath(), { ...backstageJson, version }, { spaces: 2, @@ -403,6 +435,53 @@ export async function bumpBackstageJsonVersion(version: string) { ); } +async function asLockfileVersion(version: string) { + if (version === 'backstage:^') { + return `backstage:${(await getBackstageJson())?.version}`; + } + + return version; +} + +const yarnRcSchema = z.object({ + plugins: z + .array( + z.object({ + path: z.string(), + }), + ) + .optional(), +}); + +async function getHasYarnPlugin() { + const yarnRcPath = paths.resolveTargetRoot('.yarnrc.yml'); + const yarnRcContent = await fs.readFile(yarnRcPath, 'utf-8').catch(e => { + if (e.code === 'ENOENT') { + // gracefully continue in case the file doesn't exist + return ''; + } + throw e; + }); + + if (!yarnRcContent) { + return false; + } + + const parseResult = yarnRcSchema.safeParse(yaml.load(yarnRcContent)); + + if (!parseResult.success) { + throw new Error( + `Unexpected content in .yarnrc.yml: ${parseResult.error.toString()}`, + ); + } + + const yarnRc = parseResult.data; + + return yarnRc.plugins?.some( + plugin => plugin.path === '.yarn/plugins/@yarnpkg/plugin-backstage.cjs', + ); +} + export async function runYarnInstall() { const spinner = ora({ prefixText: `Running ${chalk.blue('yarn install')} to install new versions`, diff --git a/yarn.lock b/yarn.lock index 82455ac5f1..4fe52d4987 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3993,6 +3993,7 @@ __metadata: jest-css-modules: ^2.1.0 jest-environment-jsdom: ^29.0.2 jest-runtime: ^29.0.2 + js-yaml: ^4.1.0 json-schema: ^0.4.0 lodash: ^4.17.21 mini-css-extract-plugin: ^2.4.2