Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@backstage/config": "^1.3.6",
"@backstage/config-loader": "^1.10.9",
"@backstage/errors": "^1.2.7",
"@backstage/release-manifests": "^0.0.13",
"@backstage/types": "^1.2.1",
"@changesets/cli": "^2.29.4",
"@manypkg/get-packages": "^1.1.3",
Expand Down
27 changes: 22 additions & 5 deletions src/commands/export-dynamic-plugin/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ import { createRequire } from 'node:module';
import os from 'node:os';
import * as path from 'path';

import {
isBackstageVersionSpec,
resolveBackstageVersion,
} from '../../lib/backstageVersion';
import { productionPack } from '../../lib/packager/productionPack';
import { paths } from '../../lib/paths';
import { Task } from '../../lib/tasks';
Expand Down Expand Up @@ -742,11 +746,7 @@ export function customizeForDynamicUse(options: {
version: relatedMonoRepoPackages[0].packageJson.version,
})
) {
resolvedVersion =
rangeSpecifier === '^' || rangeSpecifier === '~'
? rangeSpecifier +
relatedMonoRepoPackages[0].packageJson.version
: relatedMonoRepoPackages[0].packageJson.version;
resolvedVersion = relatedMonoRepoPackages[0].packageJson.version;
}
}

Expand All @@ -758,7 +758,24 @@ export function customizeForDynamicUse(options: {
);
}

if (rangeSpecifier === '^' || rangeSpecifier === '~') {
resolvedVersion = rangeSpecifier + resolvedVersion;
}
pkgToCustomize.dependencies[dep] = resolvedVersion;
} else if (isBackstageVersionSpec(dependencyVersionSpec)) {
// Handle backstage:^ protocol - resolve to concrete version from release manifest
const resolvedVersion = await resolveBackstageVersion(
dep,
dependencyVersionSpec,
);
if (resolvedVersion) {
Task.log(
` resolving ${chalk.cyan(dep)} from ${chalk.yellow(
dependencyVersionSpec,
)} to ${chalk.green(resolvedVersion)}`,
);
pkgToCustomize.dependencies[dep] = resolvedVersion;
}
}

if (isPackageShared(dep, options.sharedPackages)) {
Expand Down
193 changes: 193 additions & 0 deletions src/lib/backstageVersion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
* 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.
*/

/**
* This module provides utilities for resolving `backstage:^` version specs
* to concrete versions using the Backstage release manifests.
*
* It replicates the logic from the Backstage yarn plugin's beforeWorkspacePacking
* hook, which is not directly importable since the yarn plugin is private and
* bundled specifically for Yarn's plugin system.
*
* Environment variables (compatible with the Backstage yarn plugin):
* - BACKSTAGE_MANIFEST_FILE: Path to a local manifest file (for offline usage)
* - BACKSTAGE_VERSIONS_BASE_URL: Custom base URL for fetching manifests
*/

import { BACKSTAGE_JSON } from '@backstage/cli-common';
import {
getManifestByVersion,
ReleaseManifest,
} from '@backstage/release-manifests';
import * as fs from 'fs-extra';
import * as path from 'path';

Check warning on line 36 in src/lib/backstageVersion.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-cli&issues=AZ3QABWeT_z8tbmMNW79&open=AZ3QABWeT_z8tbmMNW79&pullRequest=103
import * as semver from 'semver';

import { paths } from './paths';

const PROTOCOL = 'backstage:';

/**
* Cache for the release manifest to avoid fetching it multiple times
*/
let cachedManifest:
| { version: string; packages: Map<string, string> }
| undefined;

/**
* Gets the current Backstage version from backstage.json
*/
export async function getCurrentBackstageVersion(): Promise<
string | undefined
> {
// Try to find backstage.json in the target directory or monorepo root
const possiblePaths = [
path.join(paths.targetDir, BACKSTAGE_JSON),
path.join(paths.targetRoot, BACKSTAGE_JSON),
];

for (const backstageJsonPath of possiblePaths) {
if (await fs.pathExists(backstageJsonPath)) {
try {
const backstageJson = await fs.readJson(backstageJsonPath);
const version = backstageJson.version;
if (version && semver.valid(version)) {
return version;
}
} catch {
// Continue to next path
}
}
}

return undefined;
}

/**
* Fetches and caches the Backstage release manifest for the given version.
*
* Supports the same environment variables as the Backstage yarn plugin:
* - BACKSTAGE_MANIFEST_FILE: Read manifest from a local file instead of fetching
* - BACKSTAGE_VERSIONS_BASE_URL: Custom base URL for fetching manifests
*/
async function getBackstageManifest(
backstageVersion: string,
): Promise<Map<string, string>> {
if (cachedManifest && cachedManifest.version === backstageVersion) {

Check warning on line 89 in src/lib/backstageVersion.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-cli&issues=AZ3QABWeT_z8tbmMNW7-&open=AZ3QABWeT_z8tbmMNW7-&pullRequest=103
return cachedManifest.packages;
}

let manifest: ReleaseManifest;

// Support BACKSTAGE_MANIFEST_FILE for offline usage (same as yarn plugin)
const manifestFile = process.env.BACKSTAGE_MANIFEST_FILE;
if (manifestFile) {
try {
manifest = await fs.readJson(manifestFile);
} catch (error) {
throw new Error(
`Failed to read Backstage manifest from BACKSTAGE_MANIFEST_FILE="${manifestFile}": ${error}`,
);
}
} else {
try {
manifest = await getManifestByVersion({
version: backstageVersion,
// Support BACKSTAGE_VERSIONS_BASE_URL for custom manifest server (same as yarn plugin)
versionsBaseUrl: process.env.BACKSTAGE_VERSIONS_BASE_URL,
});
} catch (error) {
const baseUrl =
process.env.BACKSTAGE_VERSIONS_BASE_URL ||
'https://versions.backstage.io';
throw new Error(
`Failed to fetch Backstage release manifest for version ${backstageVersion} from ${baseUrl}: ${error}\n\n` +
`To resolve this issue, you can:\n` +
` - Check your network connection\n` +
` - Set BACKSTAGE_VERSIONS_BASE_URL to use a different manifest server\n` +
` - Set BACKSTAGE_MANIFEST_FILE to use a local manifest file for offline usage\n` +
` (Download from: ${baseUrl}/v1/releases/${backstageVersion}/manifest.json)`,
);
}
}

const packages = new Map<string, string>();
for (const pkg of manifest.packages) {
packages.set(pkg.name, pkg.version);
}

cachedManifest = { version: backstageVersion, packages };
return packages;
}

/**
* Checks if a version spec uses the backstage: protocol
*/
export function isBackstageVersionSpec(versionSpec: string): boolean {
return versionSpec.startsWith(PROTOCOL);
}

/**
* Resolves a backstage:^ version spec to a concrete version.
*
* @param packageName - The name of the package to resolve
* @param versionSpec - The version spec (e.g., "backstage:^")
* @returns The resolved version (e.g., "^1.23.0") or undefined if not found
*/
export async function resolveBackstageVersion(
packageName: string,
versionSpec: string,
): Promise<string | undefined> {
if (!isBackstageVersionSpec(versionSpec)) {
return undefined;
}

const selector = versionSpec.slice(PROTOCOL.length);
if (selector !== '^') {
throw new Error(
`Unsupported backstage: version selector "${selector}" for package "${packageName}". Only "backstage:^" is supported.`,
);
}

const backstageVersion = await getCurrentBackstageVersion();
if (!backstageVersion) {
throw new Error(
`Cannot resolve "${versionSpec}" for package "${packageName}": ` +
`No backstage.json file found with a valid version. ` +
`Make sure backstage.json exists in the project or monorepo root.`,
);
}

const manifest = await getBackstageManifest(backstageVersion);
const resolvedVersion = manifest.get(packageName);

if (!resolvedVersion) {
throw new Error(
`Package "${packageName}" not found in Backstage release manifest for version ${backstageVersion}. ` +
`This package may not be part of the Backstage release, or may have been renamed/removed. ` +
`You may need to specify an explicit version instead of "${versionSpec}".`,
);
}

return `^${resolvedVersion}`;
}

/**
* Clears the cached manifest (useful for testing)
*/
export function clearManifestCache(): void {
cachedManifest = undefined;
}
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4609,6 +4609,7 @@ __metadata:
"@backstage/core-plugin-api": 1.10.3
"@backstage/errors": ^1.2.7
"@backstage/eslint-plugin": 0.2.2
"@backstage/release-manifests": ^0.0.13
"@backstage/repo-tools": ^0.13.3
"@backstage/types": ^1.2.1
"@changesets/cli": ^2.29.4
Expand Down
Loading