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/src/commands/versions/bump.ts b/packages/cli/src/commands/versions/bump.ts index 5b981d3890..e41bd98c49 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 '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,8 @@ type PkgVersionInfo = { export default async (opts: OptionValues) => { const lockfilePath = paths.resolveTargetRoot('yarn.lock'); const lockfile = await Lockfile.load(lockfilePath); + const hasYarnPlugin = await getHasYarnPlugin(); + let pattern = opts.pattern; if (!pattern) { @@ -177,12 +181,34 @@ 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); + + const useBackstageRange = + hasYarnPlugin && + // Only use backstage:^ versions if the package is present in + // the manifest for the release we're bumping to. + releaseManifest.packages.find( + ({ name: manifestPackageName }) => + dep.name === manifestPackageName, + ) && + // Don't use backstage:^ versions for peerDependencies; they only + // support npm and workspace: versions. + 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; @@ -266,6 +292,21 @@ export default async (opts: OptionValues) => { console.log(); } + if (hasYarnPlugin) { + console.log(); + console.log( + chalk.blue( + `${chalk.bold( + 'NOTE', + )}: this bump used backstage:^ versions in package.json files, since the Backstage ` + + `yarn plugin was detected in the repository. To migrate back to explicit npm versions, ` + + `remove the plugin by running "yarn plugin remove @yarnpkg/plugin-backstage", then ` + + `repeat this command.`, + ), + ); + console.log(); + } + console.log(chalk.green('Version bump complete!')); } @@ -354,16 +395,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 +442,7 @@ export async function bumpBackstageJsonVersion(version: string) { } await fs.writeJson( - backstageJsonPath, + getBackstageJsonPath(), { ...backstageJson, version }, { spaces: 2, @@ -403,6 +451,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.parse(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`,