diff --git a/CHANGELOG.md b/CHANGELOG.md index b5efea5..1ae4c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to `@red-hat-developer-hub/cli` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 2.0.5 - 2026-09-04 + +### Added + +- **`plugin check-versions`:** Add `rhdh-cli plugin check-versions` (alias `plugin versions:lint`) command and RHDH-to-Backstage version mapping engine ([RHIDP-16665](https://redhat.atlassian.net/browse/RHIDP-16665), [RHIDP-16667](https://redhat.atlassian.net/browse/RHIDP-16667), [#176](https://github.com/redhat-developer/rhdh-cli/pull/176)). Supports auditing `@backstage/*` dependencies in `package.json` against target RHDH release manifests using a 3-tier resolution engine (remote GitHub build-metadata, embedded static compatibility matrix fallback, and Backstage release manifests). + ## 2.0.4 - 2026-08-27 ### Added diff --git a/README.md b/README.md index c2cbd32..6f9c49f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,20 @@ On Windows, use Git Bash or WSL so these tools are available. When you build an OCI image with `--tag` (instead of exporting to a directory with `--export-to`), a container build tool must also be on `PATH`. **podman** is the default; you can select **docker** or **buildah** with `--container-tool` (for example `--container-tool docker`). Directory-only exports with `--export-to` do not need a container tool. +## Checking Plugin Versions + +Use `plugin check-versions` to compare a plugin's `@backstage/*` dependencies with the Backstage release used by an RHDH version: + +```bash +rhdh-cli plugin check-versions --rhdh-version 2.0.0 +``` + +Use `--json` for machine-readable output. To target a Backstage version directly, prefix it with `backstage:`, for example `--rhdh-version backstage:1.54.0`. + +For air-gapped environments, provide a local release manifest with `--manifest-file`. `--manifest-file` avoids the Backstage manifest download; also set `RHDH_OFFLINE=true` to skip the RHDH GitHub metadata lookup. + +When adding support for a new RHDH release, update `RHDH_COMPATIBILITY_MATRIX` in `src/lib/rhdhVersion.ts` with its Backstage version before releasing the corresponding CLI version. This matrix is maintained manually until its release metadata can be automated. + ## Development ### Contributing diff --git a/package.json b/package.json index 561b23e..b9caabf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@red-hat-developer-hub/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "2.0.4", + "version": "2.0.5", "publishConfig": { "access": "public" }, diff --git a/src/commands/check-versions/command.test.ts b/src/commands/check-versions/command.test.ts new file mode 100644 index 0000000..3aac891 --- /dev/null +++ b/src/commands/check-versions/command.test.ts @@ -0,0 +1,231 @@ +/* + * Copyright 2026 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 fs from 'fs-extra'; +import os from 'os'; +import path from 'node:path'; + +import { ExitCodeError } from '../../lib/errors'; +import { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import { checkPluginDependencies, command } from './command'; + +jest.mock('../../lib/rhdhVersion', () => ({ + ...jest.requireActual('../../lib/rhdhVersion'), + resolveRhdhVersion: jest.fn(), +})); + +describe('checkPluginDependencies', () => { + let tmpDir: string; + let originalCwd: string; + const mockResolveRhdhVersion = resolveRhdhVersion as jest.MockedFunction< + typeof resolveRhdhVersion + >; + + async function setupFixture( + pkg: { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, + manifestPackages: [string, string][] = [ + ['@backstage/core-plugin-api', '1.12.0'], + ], + ) { + await fs.writeJson(path.join(tmpDir, 'package.json'), { + name: 'test-plugin', + ...pkg, + }); + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + source: 'matrix', + packages: new Map(manifestPackages), + }); + } + + async function runCommandWithOutput(opts: any = {}) { + let stdout = ''; + let stderr = ''; + let error: Error | undefined; + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation((chunk: any) => { + stdout += chunk; + return true; + }); + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: any) => { + stderr += chunk; + return true; + }); + + try { + await command(opts); + } catch (caughtError) { + error = caughtError as Error; + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + + return { stdout, stderr, error }; + } + + beforeEach(async () => { + originalCwd = process.cwd(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'check-versions-test-')); + process.chdir(tmpDir); + process.exitCode = undefined; + jest.clearAllMocks(); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(tmpDir); + process.exitCode = undefined; + }); + + it('throws error when package.json does not exist', async () => { + await expect( + checkPluginDependencies({ targetDir: tmpDir }), + ).rejects.toThrow(/No package\.json found/); + }); + + it('reports matching dependencies when versions align with manifest', async () => { + await setupFixture( + { + dependencies: { + '@backstage/core-plugin-api': '^1.12.0', + '@backstage/catalog-model': '~1.7.6', + }, + devDependencies: { + '@backstage/cli': '0.36.3', + }, + peerDependencies: { + '@backstage/config': 'backstage:^', + }, + }, + [ + ['@backstage/core-plugin-api', '1.12.0'], + ['@backstage/catalog-model', '1.7.6'], + ['@backstage/cli', '0.36.3'], + ['@backstage/config', '1.3.8'], + ], + ); + + const result = await checkPluginDependencies({ targetDir: tmpDir }); + + expect(result.valid).toBe(true); + expect(result.counts.matching).toBe(3); + expect(result.counts.mismatched).toBe(0); + expect(result.counts.unmanifested).toBe(0); + expect(result.counts.unverifiable).toBe(1); + expect( + result.packages.find(p => p.name === '@backstage/config')?.status, + ).toBe('unverifiable'); + }); + + it('reports mismatched and unmanifested dependencies when versions differ', async () => { + await setupFixture( + { + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + '@backstage/unknown-pkg': '^1.0.0', + lodash: '^4.17.21', + }, + devDependencies: { + '@backstage/cli': '^0.30.0', + }, + }, + [ + ['@backstage/core-plugin-api', '1.12.0'], + ['@backstage/cli', '0.36.3'], + ], + ); + + const result = await checkPluginDependencies({ targetDir: tmpDir }); + + expect(result.valid).toBe(false); + expect(result.counts.matching).toBe(0); + expect(result.counts.mismatched).toBe(2); + expect(result.counts.unmanifested).toBe(1); + expect(result.counts.unverifiable).toBe(0); + expect(result.counts.total).toBe(3); + + const corePluginApi = result.packages.find( + p => p.name === '@backstage/core-plugin-api', + ); + expect(corePluginApi?.status).toBe('mismatch'); + expect(corePluginApi?.declared).toBe('^1.9.0'); + expect(corePluginApi?.expected).toBe('1.12.0'); + + const unknownPkg = result.packages.find( + p => p.name === '@backstage/unknown-pkg', + ); + expect(unknownPkg?.status).toBe('unmanifested'); + expect(unknownPkg?.expected).toBeUndefined(); + }); + + describe('CLI command handler', () => { + it('reports backstage:^ dependencies as unverifiable', async () => { + await setupFixture( + { peerDependencies: { '@backstage/config': 'backstage:^' } }, + [['@backstage/config', '1.3.8']], + ); + + const res = await runCommandWithOutput({}); + expect(res.stderr).toContain('cannot verify backstage:^'); + expect(res.stderr).toContain('cannot be verified'); + expect(res.error).toBeUndefined(); + }); + + it('outputs JSON when --json flag is passed and sets exitCode on failure', async () => { + await setupFixture({ + dependencies: { '@backstage/core-plugin-api': '^1.9.0' }, + }); + + const res = await runCommandWithOutput({ json: true }); + const parsed = JSON.parse(res.stdout); + expect(parsed.valid).toBe(false); + expect(parsed.counts.mismatched).toBe(1); + expect(res.error).toEqual(new ExitCodeError(1)); + }); + + it('prints formatted table and remediation when run in human mode', async () => { + await setupFixture({ + dependencies: { '@backstage/core-plugin-api': '^1.9.0' }, + }); + + const res = await runCommandWithOutput({}); + expect(res.stderr).toContain('Package'); + expect(res.stderr).toContain('@backstage/core-plugin-api'); + expect(res.stderr).toContain('mismatch'); + expect(res.stderr).toContain('rhdh-cli plugin upgrade 2.0.0'); + expect(res.error).toEqual(new ExitCodeError(1)); + }); + + it('prints success message when dependencies are aligned', async () => { + await setupFixture({ + dependencies: { '@backstage/core-plugin-api': '^1.12.0' }, + }); + + const res = await runCommandWithOutput({}); + expect(res.stderr).toContain('All @backstage dependencies are aligned'); + expect(res.error).toBeUndefined(); + }); + }); +}); diff --git a/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts new file mode 100644 index 0000000..735993a --- /dev/null +++ b/src/commands/check-versions/command.ts @@ -0,0 +1,297 @@ +/* + * Copyright 2026 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 chalk from 'chalk'; +import { OptionValues } from 'commander'; +import fs from 'fs-extra'; +import path from 'node:path'; +import semver from 'semver'; + +import { ExitCodeError } from '../../lib/errors'; +import { paths } from '../../lib/paths'; +import { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import { Task } from '../../lib/tasks'; + +export type DependencyStatus = + | 'match' + | 'mismatch' + | 'unmanifested' + | 'unverifiable'; +export type DependencySection = + | 'dependencies' + | 'devDependencies' + | 'peerDependencies'; + +export interface PackageCheckResult { + name: string; + section: DependencySection; + declared: string; + expected?: string; + status: DependencyStatus; +} + +export interface CheckVersionsResult { + rhdhVersion: string; + backstageVersion: string; + source: 'remote' | 'matrix' | 'explicit'; + valid: boolean; + counts: { + matching: number; + mismatched: number; + unmanifested: number; + unverifiable: number; + total: number; + }; + packages: PackageCheckResult[]; +} + +export interface CheckVersionsOptions { + rhdhVersion?: string; + manifestFile?: string; + targetDir?: string; +} + +/** + * Determines if a declared version string is aligned with the manifest expected version. + */ +function isVersionAligned( + declaredVersion: string, + expectedVersion: string, +): boolean { + const cleanedDeclared = declaredVersion.replace(/^[\^~]/, ''); + if (cleanedDeclared === expectedVersion) { + return true; + } + + const parsedDeclared = semver.clean(declaredVersion); + return parsedDeclared === expectedVersion; +} + +/** + * Audits a single dependency against the Backstage release manifest + */ +function auditDependency( + name: string, + declaredVersion: string, + section: DependencySection, + manifestPackages: Map, +): PackageCheckResult | undefined { + const isBackstagePkg = name.startsWith('@backstage/'); + const expectedVersion = manifestPackages.get(name); + + if (!isBackstagePkg && !expectedVersion) { + return undefined; + } + + if (!expectedVersion) { + return { + name, + section, + declared: declaredVersion, + status: 'unmanifested', + }; + } + + if (declaredVersion === 'backstage:^') { + return { + name, + section, + declared: declaredVersion, + expected: expectedVersion, + status: 'unverifiable', + }; + } + + const isMatch = isVersionAligned(declaredVersion, expectedVersion); + return { + name, + section, + declared: declaredVersion, + expected: expectedVersion, + status: isMatch ? 'match' : 'mismatch', + }; +} + +/** + * Checks dependency alignment for a package.json against a target RHDH version + */ +export async function checkPluginDependencies( + options: CheckVersionsOptions = {}, +): Promise { + const targetDir = options.targetDir || paths.targetDir; + const packageJsonPath = path.join(targetDir, 'package.json'); + + if (!(await fs.pathExists(packageJsonPath))) { + throw new Error( + `No package.json found at "${targetDir}". Make sure you run this command inside a plugin package directory.`, + ); + } + + const packageJson = await fs.readJson(packageJsonPath); + const resolved = await resolveRhdhVersion(options.rhdhVersion, { + manifestFile: options.manifestFile, + }); + + const sections: DependencySection[] = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + ]; + + const packages: PackageCheckResult[] = []; + + for (const section of sections) { + const deps = packageJson[section] as Record | undefined; + if (!deps) continue; + + for (const [name, declaredVersion] of Object.entries(deps)) { + const audited = auditDependency( + name, + declaredVersion, + section, + resolved.packages, + ); + if (audited) { + packages.push(audited); + } + } + } + + const matching = packages.filter(p => p.status === 'match').length; + const mismatched = packages.filter(p => p.status === 'mismatch').length; + const unmanifested = packages.filter(p => p.status === 'unmanifested').length; + const unverifiable = packages.filter(p => p.status === 'unverifiable').length; + const valid = mismatched === 0 && unmanifested === 0; + + return { + rhdhVersion: resolved.rhdhVersion, + backstageVersion: resolved.backstageVersion, + source: resolved.source, + valid, + counts: { + matching, + mismatched, + unmanifested, + unverifiable, + total: packages.length, + }, + packages, + }; +} + +/** + * CLI command entry point for `rhdh-cli plugin check-versions` + */ +export async function command(opts: OptionValues): Promise { + const { rhdhVersion, manifestFile, json } = opts; + + const result = await checkPluginDependencies({ + rhdhVersion, + manifestFile, + }); + + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.valid) { + throw new ExitCodeError(1); + } + return; + } + + Task.log( + `Checking plugin dependencies against RHDH v${result.rhdhVersion} (Backstage v${result.backstageVersion}) [${result.source}]...`, + ); + + if (result.packages.length === 0) { + Task.log('No @backstage dependencies found in package.json.'); + return; + } + + process.stderr.write('\n'); + + // Calculate column widths for clean tabular output + const colNameWidth = Math.max( + ...result.packages.map(p => p.name.length), + 'Package'.length, + ); + const colSecWidth = Math.max( + ...result.packages.map(p => p.section.length), + 'Section'.length, + ); + const colDeclWidth = Math.max( + ...result.packages.map(p => p.declared.length), + 'Declared'.length, + ); + const colExpWidth = Math.max( + ...result.packages.map(p => (p.expected || '-').length), + 'Expected'.length, + ); + + const header = `${'Package'.padEnd(colNameWidth)} ${'Section'.padEnd(colSecWidth)} ${'Declared'.padEnd(colDeclWidth)} ${'Expected'.padEnd(colExpWidth)} Status`; + process.stderr.write(`${chalk.bold(header)}\n`); + process.stderr.write( + `${chalk.gray('-'.repeat(header.length + ' Status'.length))}\n`, + ); + + for (const pkg of result.packages) { + let statusLabel: string; + if (pkg.status === 'match') { + statusLabel = chalk.green('✓ match'); + } else if (pkg.status === 'mismatch') { + statusLabel = chalk.red('✗ mismatch'); + } else if (pkg.status === 'unverifiable') { + statusLabel = chalk.yellow('⚠ cannot verify backstage:^'); + } else { + statusLabel = chalk.yellow('⚠ unmanifested'); + } + + const line = `${pkg.name.padEnd(colNameWidth)} ${pkg.section.padEnd(colSecWidth)} ${pkg.declared.padEnd(colDeclWidth)} ${(pkg.expected || '-').padEnd(colExpWidth)} ${statusLabel}`; + process.stderr.write(`${line}\n`); + } + + process.stderr.write('\n'); + + // Summary line without nested template literals + const matchStr = chalk.green(`✓ ${result.counts.matching} matching`); + const mismatchStr = chalk.red(`✗ ${result.counts.mismatched} mismatched`); + const unmanifestedStr = chalk.yellow( + `⚠ ${result.counts.unmanifested} unmanifested`, + ); + const unverifiableStr = chalk.yellow( + `⚠ ${result.counts.unverifiable} unverifiable`, + ); + const summary = `${matchStr}, ${mismatchStr}, ${unmanifestedStr}, ${unverifiableStr} (${result.counts.total} total)`; + process.stderr.write(`${chalk.bold('Summary:')} ${summary}\n`); + + if (!result.valid) { + const upgradeCmd = chalk.cyan( + `rhdh-cli plugin upgrade ${result.rhdhVersion}`, + ); + process.stderr.write( + `\n${chalk.yellow('Remediation:')} Run ${upgradeCmd} to align dependencies with RHDH v${result.rhdhVersion}.\n\n`, + ); + throw new ExitCodeError(1); + } else if (result.counts.unverifiable > 0) { + const message = chalk.yellow( + `⚠ ${result.counts.unverifiable} backstage:^ dependencies cannot be verified against the target RHDH release.`, + ); + process.stderr.write(`\n${message}\n\n`); + } else { + process.stderr.write( + `\n${chalk.green('✔ All @backstage dependencies are aligned with target RHDH release.')}\n\n`, + ); + } +} diff --git a/src/commands/check-versions/index.ts b/src/commands/check-versions/index.ts new file mode 100644 index 0000000..ebaffcf --- /dev/null +++ b/src/commands/check-versions/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 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 { command } from './command'; diff --git a/src/commands/index.ts b/src/commands/index.ts index db625d9..ab4c0db 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -18,7 +18,7 @@ import { assertError } from '@backstage/errors'; import { Command } from 'commander'; -import { exitWithError } from '../lib/errors'; +import { ExitCodeError, exitWithError } from '../lib/errors'; import { registerIntentCommands } from './intent-based-actions'; export function registerPluginCommand(program: Command) { @@ -146,7 +146,24 @@ export function registerPluginCommand(program: Command) { .action( lazy(() => import('./package-dynamic-plugins').then(m => m.command)), ); + + command + .command('check-versions') + .description( + 'Check dynamic plugin dependencies in package.json against target RHDH release Backstage manifest', + ) + .option( + '--rhdh-version ', + 'Target RHDH version to check compatibility against (e.g. 2.0.0, 1.9, latest, backstage:1.54.0)', + ) + .option( + '--manifest-file ', + 'Path to a local Backstage release manifest JSON file (required for air-gapped use)', + ) + .option('--json', 'Output results as JSON') + .action(lazy(() => import('./check-versions').then(m => m.command))); } + export function registerCommands(program: Command) { registerPluginCommand(program); registerIntentCommands(program); @@ -164,6 +181,9 @@ function lazy( process.exit(0); } catch (error) { assertError(error); + if (error instanceof ExitCodeError) { + process.exit(error.code); + } exitWithError(error); } }; diff --git a/src/lib/backstageVersion.ts b/src/lib/backstageVersion.ts index a76869e..b17fb80 100644 --- a/src/lib/backstageVersion.ts +++ b/src/lib/backstageVersion.ts @@ -44,7 +44,11 @@ const PROTOCOL = 'backstage:'; * Cache for the release manifest to avoid fetching it multiple times */ let cachedManifest: - | { version: string; packages: Map } + | { + version: string; + versionsBaseUrl?: string; + packages: Map; + } | undefined; /** @@ -83,17 +87,29 @@ export async function getCurrentBackstageVersion(): Promise< * - 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( +export async function getBackstageManifest( backstageVersion: string, + options?: { + manifestFile?: string; + versionsBaseUrl?: string; + }, ): Promise> { - if (cachedManifest && cachedManifest.version === backstageVersion) { + const manifestFile = + options?.manifestFile || process.env.BACKSTAGE_MANIFEST_FILE; + const versionsBaseUrl = + options?.versionsBaseUrl || process.env.BACKSTAGE_VERSIONS_BASE_URL; + + if ( + cachedManifest?.version === backstageVersion && + cachedManifest.versionsBaseUrl === versionsBaseUrl && + !manifestFile + ) { 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); @@ -107,12 +123,10 @@ async function getBackstageManifest( 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, + versionsBaseUrl, }); } catch (error) { - const baseUrl = - process.env.BACKSTAGE_VERSIONS_BASE_URL || - 'https://versions.backstage.io'; + const baseUrl = versionsBaseUrl || '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` + @@ -129,7 +143,7 @@ async function getBackstageManifest( packages.set(pkg.name, pkg.version); } - cachedManifest = { version: backstageVersion, packages }; + cachedManifest = { version: backstageVersion, versionsBaseUrl, packages }; return packages; } diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts new file mode 100644 index 0000000..de56319 --- /dev/null +++ b/src/lib/rhdhVersion.test.ts @@ -0,0 +1,414 @@ +/* + * Copyright 2026 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 fs from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +import { + clearManifestCache, + getCurrentBackstageVersion, +} from './backstageVersion'; +import { + clearRhdhVersionCache, + DEFAULT_RHDH_VERSION, + fetchRemoteRhdhMetadata, + findStaticMatrixBackstageVersion, + getRhdhGitRef, + getSupportedRhdhVersions, + normalizeRhdhVersion, + resolveRhdhVersion, +} from './rhdhVersion'; + +jest.mock('./backstageVersion', () => ({ + ...jest.requireActual('./backstageVersion'), + getCurrentBackstageVersion: jest.fn(), +})); + +describe('rhdhVersion', () => { + const originalFetch = globalThis.fetch; + const mockGetCurrentBackstageVersion = + getCurrentBackstageVersion as jest.MockedFunction< + typeof getCurrentBackstageVersion + >; + + function setupFetchMock({ + metadata, + metadataError, + manifestVersion = '1.52.0', + packages = [], + manifestError, + }: { + metadata?: any; + metadataError?: Error; + manifestVersion?: string; + packages?: { name: string; version: string }[]; + manifestError?: Error; + } = {}) { + const fn = jest.fn().mockImplementation((url: string) => { + if (url.includes('build-metadata.json')) { + if (metadataError) return Promise.reject(metadataError); + if (metadata) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => metadata, + } as any); + } + return Promise.resolve({ ok: false, status: 404 } as any); + } + if (url.includes('manifest.json')) { + if (manifestError) return Promise.reject(manifestError); + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + releaseVersion: manifestVersion, + packages, + }), + } as any); + } + return Promise.reject(new Error(`Unexpected url: ${url}`)); + }); + globalThis.fetch = fn; + return fn; + } + + beforeEach(() => { + clearRhdhVersionCache(); + clearManifestCache(); + delete process.env.RHDH_OFFLINE; + delete process.env.BACKSTAGE_MANIFEST_FILE; + delete process.env.BACKSTAGE_VERSIONS_BASE_URL; + mockGetCurrentBackstageVersion.mockReset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('normalizeRhdhVersion', () => { + it('returns default version when no input provided', () => { + expect(DEFAULT_RHDH_VERSION).toBe('2.1.0'); + expect(normalizeRhdhVersion()).toBe(DEFAULT_RHDH_VERSION); + expect(normalizeRhdhVersion('')).toBe(DEFAULT_RHDH_VERSION); + }); + + it('normalizes latest and stable aliases', () => { + expect(normalizeRhdhVersion('latest')).toBe(DEFAULT_RHDH_VERSION); + expect(normalizeRhdhVersion('STABLE')).toBe(DEFAULT_RHDH_VERSION); + }); + + it('normalizes next and main aliases', () => { + expect(normalizeRhdhVersion('next')).toBe('main'); + expect(normalizeRhdhVersion('main')).toBe('main'); + }); + + it('preserves backstage: prefix', () => { + expect(normalizeRhdhVersion('backstage:1.54.0')).toBe('backstage:1.54.0'); + }); + + it('strips leading v from version strings', () => { + expect(normalizeRhdhVersion('v2.0.0')).toBe('2.0.0'); + expect(normalizeRhdhVersion('V1.9.0')).toBe('1.9.0'); + expect(normalizeRhdhVersion('v2.0')).toBe('2.0'); + }); + }); + + describe('getRhdhGitRef', () => { + it('maps main and next to main branch', () => { + expect(getRhdhGitRef('main')).toBe('main'); + expect(getRhdhGitRef('next')).toBe('main'); + }); + + it('maps semver releases to release-X.Y branches', () => { + expect(getRhdhGitRef('2.0.0')).toBe('release-2.0'); + expect(getRhdhGitRef('2.0')).toBe('release-2.0'); + expect(getRhdhGitRef('1.9.0')).toBe('release-1.9'); + expect(getRhdhGitRef('1.10.0')).toBe('release-1.10'); + }); + + it('rejects unsafe branch names', () => { + expect(getRhdhGitRef('../main')).toBeUndefined(); + }); + }); + + describe('findStaticMatrixBackstageVersion', () => { + it('finds exact versions in matrix', () => { + expect(findStaticMatrixBackstageVersion('2.0.0')).toBe('1.52.0'); + expect(findStaticMatrixBackstageVersion('1.9.0')).toBe('1.45.3'); + expect(findStaticMatrixBackstageVersion('1.8.0')).toBe('1.42.5'); + expect(findStaticMatrixBackstageVersion('main')).toBe('1.54.0'); + }); + + it('resolves minor versions without patch to matrix entry', () => { + expect(findStaticMatrixBackstageVersion('2.0')).toBe('1.52.0'); + expect(findStaticMatrixBackstageVersion('1.9')).toBe('1.45.3'); + expect(findStaticMatrixBackstageVersion('1.10')).toBe('1.49.4'); + }); + + it('returns undefined for unknown versions', () => { + expect(findStaticMatrixBackstageVersion('0.1.0')).toBeUndefined(); + expect(findStaticMatrixBackstageVersion('unknown')).toBeUndefined(); + }); + }); + + describe('getSupportedRhdhVersions', () => { + it('returns unique sorted supported versions list', () => { + const versions = getSupportedRhdhVersions(); + expect(versions).toContain('2.0.0'); + expect(versions).toContain('1.9.0'); + expect(versions).not.toContain('main'); + expect(versions).not.toContain('next'); + }); + }); + + describe('fetchRemoteRhdhMetadata', () => { + it('fetches and parses remote build-metadata.json successfully', async () => { + setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }, + }); + + const result = await fetchRemoteRhdhMetadata('2.0.0'); + expect(result).toEqual({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + }); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/redhat-developer/rhdh/release-2.0/packages/app/src/build-metadata.json', + expect.anything(), + ); + }); + + it('does not fall back to main for a missing release branch', async () => { + setupFetchMock({}); + + const result = await fetchRemoteRhdhMetadata('9.9.9'); + expect(result).toBeUndefined(); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://raw.githubusercontent.com/redhat-developer/rhdh/release-9.9/packages/app/src/build-metadata.json', + expect.anything(), + ); + }); + + it('handles network failure / fetch exception gracefully', async () => { + setupFetchMock({ metadataError: new Error('Network error') }); + + const result = await fetchRemoteRhdhMetadata('2.0.0'); + expect(result).toBeUndefined(); + }); + + it('rejects invalid Backstage versions from remote metadata', async () => { + setupFetchMock({ + metadata: { card: { 'Backstage Version': 'not-a-version' } }, + }); + + await expect(fetchRemoteRhdhMetadata('2.0.0')).resolves.toBeUndefined(); + }); + }); + + describe('resolveRhdhVersion', () => { + it('resolves remote metadata when available (Tier 1)', async () => { + setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }, + packages: [{ name: '@backstage/core-plugin-api', version: '1.12.0' }], + }); + + const resolved = await resolveRhdhVersion('2.0.0'); + expect(resolved.rhdhVersion).toBe('2.0.0'); + expect(resolved.backstageVersion).toBe('1.52.0'); + expect(resolved.source).toBe('remote'); + expect(resolved.packages.get('@backstage/core-plugin-api')).toBe( + '1.12.0', + ); + }); + + it('resolves direct Backstage version when requested', async () => { + setupFetchMock({ + manifestVersion: '1.54.0', + packages: [{ name: '@backstage/core-plugin-api', version: '1.14.0' }], + }); + + const resolved = await resolveRhdhVersion('backstage:1.54.0'); + expect(resolved.backstageVersion).toBe('1.54.0'); + expect(resolved.rhdhVersion).toBe('backstage:1.54.0'); + expect(resolved.source).toBe('explicit'); + expect(resolved.packages.get('@backstage/core-plugin-api')).toBe( + '1.14.0', + ); + }); + + it('normalizes two-component explicit Backstage versions', async () => { + setupFetchMock({ packages: [] }); + + const resolved = await resolveRhdhVersion('backstage:1.54'); + + expect(resolved.backstageVersion).toBe('1.54.0'); + expect(resolved.rhdhVersion).toBe('backstage:1.54.0'); + }); + + it('uses the current Backstage version to select the default RHDH target', async () => { + mockGetCurrentBackstageVersion.mockResolvedValue('1.45.3'); + setupFetchMock({ packages: [] }); + + const resolved = await resolveRhdhVersion(undefined, { offline: true }); + + expect(resolved.rhdhVersion).toBe('1.9.0'); + expect(resolved.backstageVersion).toBe('1.45.3'); + }); + + it('rejects an ambiguous bare Backstage version', async () => { + setupFetchMock({}); + + await expect(resolveRhdhVersion('1.54.0')).rejects.toThrow( + /Unsupported or unknown RHDH version "1.54.0"/, + ); + }); + + it('falls back to static compatibility matrix (Tier 2) when remote fails', async () => { + setupFetchMock({ + metadataError: new Error('Network unreachable'), + manifestVersion: '1.45.3', + packages: [{ name: '@backstage/core-plugin-api', version: '1.10.9' }], + }); + + const resolved = await resolveRhdhVersion('1.9.0'); + expect(resolved.rhdhVersion).toBe('1.9.0'); + expect(resolved.backstageVersion).toBe('1.45.3'); + expect(resolved.source).toBe('matrix'); + expect(resolved.packages.get('@backstage/core-plugin-api')).toBe( + '1.10.9', + ); + }); + + it('skips remote lookup when offline option is provided', async () => { + const fetchMock = setupFetchMock({ + manifestVersion: '1.52.0', + packages: [], + }); + + const resolved = await resolveRhdhVersion('2.0.0', { offline: true }); + expect(resolved.source).toBe('matrix'); + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining('build-metadata.json'), + expect.anything(), + ); + }); + + it('uses a local manifest without skipping remote metadata lookup', async () => { + const manifestFile = path.join( + await fs.mkdtemp(path.join(os.tmpdir(), 'rhdh-version-test-')), + 'manifest.json', + ); + await fs.writeJson(manifestFile, { + packages: [{ name: '@backstage/core-plugin-api', version: '1.12.0' }], + }); + const fetchMock = setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }, + }); + + const resolved = await resolveRhdhVersion('2.0.0', { manifestFile }); + + expect(resolved.source).toBe('remote'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('build-metadata.json'), + expect.anything(), + ); + await fs.remove(path.dirname(manifestFile)); + }); + + it('allows an explicit online option to override RHDH_OFFLINE', async () => { + process.env.RHDH_OFFLINE = 'true'; + const fetchMock = setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }, + }); + + const resolved = await resolveRhdhVersion('2.0.0', { offline: false }); + + expect(resolved.source).toBe('remote'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('build-metadata.json'), + expect.anything(), + ); + }); + + it('throws descriptive error on unknown RHDH version', async () => { + setupFetchMock({}); + + await expect(resolveRhdhVersion('999.0.0')).rejects.toThrow( + /Unsupported or unknown RHDH version "999.0.0"/, + ); + }); + + it('caches resolution results on consecutive calls', async () => { + const fetchMock = setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }, + }); + + const res1 = await resolveRhdhVersion('2.0.0'); + const res2 = await resolveRhdhVersion('2.0.0'); + + expect(res1).toBe(res2); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not reuse a manifest resolved from a different base URL', async () => { + const fetchMock = setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }, + }); + + await resolveRhdhVersion('2.0.0', { + versionsBaseUrl: 'https://one.example.test', + }); + await resolveRhdhVersion('2.0.0', { + versionsBaseUrl: 'https://two.example.test', + }); + + expect(fetchMock).toHaveBeenCalledTimes(4); + }); + }); +}); diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts new file mode 100644 index 0000000..35aaa10 --- /dev/null +++ b/src/lib/rhdhVersion.ts @@ -0,0 +1,349 @@ +/* + * Copyright 2026 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 semver from 'semver'; + +import { + getBackstageManifest, + getCurrentBackstageVersion, +} from './backstageVersion'; + +/** + * Static embedded compatibility matrix between RHDH releases and Backstage releases. + * Used for offline operations and as a fallback when remote metadata lookup is unavailable. + */ +export const RHDH_COMPATIBILITY_MATRIX: Record = { + '2.1.0': '1.54.0', + '2.0.4': '1.52.0', + '2.0.0': '1.52.0', + '1.10.0': '1.49.4', + '1.9.0': '1.45.3', + '1.8.0': '1.42.5', + '1.7.0': '1.39.1', + '1.6.0': '1.36.1', + main: '1.54.0', + next: '1.54.0', +}; + +/** + * Default stable RHDH GA release version + */ +export const DEFAULT_RHDH_VERSION = '2.1.0'; + +export type RhdhVersionSource = 'remote' | 'matrix' | 'explicit'; + +export interface ResolveRhdhVersionOptions { + manifestFile?: string; + versionsBaseUrl?: string; + offline?: boolean; +} + +export interface ResolvedRhdhVersion { + rhdhVersion: string; + backstageVersion: string; + packages: Map; + source: RhdhVersionSource; +} + +/** + * Cache for resolved RHDH versions + */ +let cachedRhdhVersions = new Map(); + +/** + * Normalizes input RHDH version string + */ +export function normalizeRhdhVersion(input?: string): string { + if (!input) { + return DEFAULT_RHDH_VERSION; + } + + const trimmed = input.trim().toLowerCase(); + + if (trimmed === 'latest' || trimmed === 'stable') { + return DEFAULT_RHDH_VERSION; + } + + if (trimmed === 'next' || trimmed === 'main') { + return 'main'; + } + + // Preserve explicit backstage: prefix + if (trimmed.startsWith('backstage:')) { + return trimmed; + } + + // Strip leading 'v' or 'v.' + return trimmed.replace(/^v\.?/, ''); +} + +/** + * Maps an RHDH version or branch name to a GitHub repository ref/branch in redhat-developer/rhdh + */ +export function getRhdhGitRef(version: string): string | undefined { + if (version === 'main' || version === 'next') { + return 'main'; + } + + if (!/^[a-z0-9._-]+$/.test(version)) { + return undefined; + } + + // For versions like 2.0.0, 2.0, 1.9.0, extract major.minor for release branch (e.g. release-2.0) + const parsed = semver.coerce(version); + if (parsed) { + return `release-${parsed.major}.${parsed.minor}`; + } + + return `release-${version}`; +} + +/** + * Fetches build-metadata.json from the target RHDH repository branch. + */ +export async function fetchRemoteRhdhMetadata( + rhdhVersion: string, + options?: { timeoutMs?: number; baseUrl?: string }, +): Promise<{ rhdhVersion: string; backstageVersion: string } | undefined> { + const gitRef = getRhdhGitRef(rhdhVersion); + if (!gitRef) { + return undefined; + } + const baseUrl = + options?.baseUrl || + process.env.RHDH_METADATA_BASE_URL || + 'https://raw.githubusercontent.com/redhat-developer/rhdh'; + + const metadataUrl = `${baseUrl}/${gitRef}/packages/app/src/build-metadata.json`; + const timeoutMs = options?.timeoutMs ?? 3000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(metadataUrl, { + signal: controller.signal, + headers: { + Accept: 'application/json', + }, + }); + + if (response.ok) { + const data = (await response.json()) as any; + const bsVersion = + data?.card?.['Backstage Version'] || + data?.card?.backstageVersion || + data?.backstageVersion; + + const resolvedRhdhVersion = + data?.card?.['RHDH Version'] || + data?.card?.rhdhVersion || + data?.rhdhVersion || + rhdhVersion; + + if (bsVersion && typeof bsVersion === 'string') { + const validBsVersion = semver.clean(bsVersion); + if (validBsVersion) { + return { + rhdhVersion: resolvedRhdhVersion, + backstageVersion: validBsVersion, + }; + } + } + } + } catch { + // Fall back to the static compatibility matrix. + } finally { + clearTimeout(timeoutId); + } + + return undefined; +} + +/** + * Finds Backstage version in static compatibility matrix + */ +export function findStaticMatrixBackstageVersion( + rhdhVersion: string, +): string | undefined { + if (RHDH_COMPATIBILITY_MATRIX[rhdhVersion]) { + return RHDH_COMPATIBILITY_MATRIX[rhdhVersion]; + } + + // Exact semver match or minor version resolution (e.g. "2.0" -> "2.0.0") + const parsed = semver.coerce(rhdhVersion); + if (parsed) { + // Check exact coerced version (e.g., 2.0 -> 2.0.0) + const exact = `${parsed.major}.${parsed.minor}.${parsed.patch}`; + if (RHDH_COMPATIBILITY_MATRIX[exact]) { + return RHDH_COMPATIBILITY_MATRIX[exact]; + } + + // Check minor version pattern match across matrix + for (const [verKey, bsVer] of Object.entries(RHDH_COMPATIBILITY_MATRIX)) { + const keyParsed = semver.coerce(verKey); + if ( + keyParsed && + keyParsed.major === parsed.major && + keyParsed.minor === parsed.minor + ) { + return bsVer; + } + } + } + + return undefined; +} + +/** + * Returns a list of all supported RHDH versions + */ +export function getSupportedRhdhVersions(): string[] { + const versions = Object.keys(RHDH_COMPATIBILITY_MATRIX).filter( + k => k !== 'main' && k !== 'next', + ); + return Array.from(new Set(versions)).sort((a, b) => { + const sA = semver.coerce(a); + const sB = semver.coerce(b); + if (sA && sB) { + return semver.rcompare(sA, sB); + } + return b.localeCompare(a); + }); +} + +/** + * Resolves default target RHDH version by inspecting current backstage.json + */ +async function getDefaultTargetVersion(): Promise { + const currentBsVersion = await getCurrentBackstageVersion(); + if (!currentBsVersion) { + return undefined; + } + for (const [rVer, bsVer] of Object.entries(RHDH_COMPATIBILITY_MATRIX)) { + if (bsVer === currentBsVersion && rVer !== 'main' && rVer !== 'next') { + return rVer; + } + } + return undefined; +} + +/** + * Resolves Backstage version using explicit version, remote metadata, or static matrix + */ +async function resolveBackstageVersionForRhdh( + normalized: string, + isOffline: boolean, +): Promise< + | { + backstageVersion: string; + resolvedRhdhVersion: string; + source: RhdhVersionSource; + } + | undefined +> { + // Support explicit Backstage versions (e.g. "backstage:1.54.0"). + if (normalized.startsWith('backstage:')) { + const bsVer = normalized.replace(/^backstage:/, '').trim(); + const parsed = semver.coerce(bsVer); + if (parsed) { + return { + backstageVersion: parsed.version, + resolvedRhdhVersion: `backstage:${parsed.version}`, + source: 'explicit', + }; + } + } + + if (!isOffline) { + const remote = await fetchRemoteRhdhMetadata(normalized); + if (remote) { + return { + backstageVersion: remote.backstageVersion, + resolvedRhdhVersion: remote.rhdhVersion, + source: 'remote', + }; + } + } + + const backstageVersion = findStaticMatrixBackstageVersion(normalized); + if (backstageVersion) { + return { + backstageVersion, + resolvedRhdhVersion: normalized, + source: 'matrix', + }; + } + + return undefined; +} + +/** + * Resolves an RHDH version query to its underlying Backstage version and package release manifest. + * + * 3-tier resolution: + * 1. Remote metadata lookup (fetching build-metadata.json from GitHub branch/tag) + * 2. Static compatibility matrix fallback (for offline or unknown remote) + * 3. Backstage release manifest fetch (via @backstage/release-manifests or local manifest file) + */ +export async function resolveRhdhVersion( + rhdhVersionInput?: string, + options?: ResolveRhdhVersionOptions, +): Promise { + const targetVersion = rhdhVersionInput || (await getDefaultTargetVersion()); + const normalized = normalizeRhdhVersion(targetVersion); + const isOffline = options?.offline ?? process.env.RHDH_OFFLINE === 'true'; + const manifestFile = + options?.manifestFile || process.env.BACKSTAGE_MANIFEST_FILE; + const versionsBaseUrl = + options?.versionsBaseUrl || process.env.BACKSTAGE_VERSIONS_BASE_URL; + const cacheKey = `${normalized}:${manifestFile || ''}:${versionsBaseUrl || ''}:${isOffline}`; + + const cached = cachedRhdhVersions.get(cacheKey); + if (cached) { + return cached; + } + + const resolved = await resolveBackstageVersionForRhdh(normalized, isOffline); + if (!resolved) { + const supported = getSupportedRhdhVersions().join(', '); + throw new Error( + `Unsupported or unknown RHDH version "${rhdhVersionInput}". ` + + `Supported versions are: ${supported} (or 'latest', 'next', 'main', or direct 'backstage:').`, + ); + } + + const packages = await getBackstageManifest(resolved.backstageVersion, { + manifestFile: options?.manifestFile, + versionsBaseUrl: options?.versionsBaseUrl, + }); + + const result: ResolvedRhdhVersion = { + rhdhVersion: resolved.resolvedRhdhVersion, + backstageVersion: resolved.backstageVersion, + packages, + source: resolved.source, + }; + + cachedRhdhVersions.set(cacheKey, result); + return result; +} + +/** + * Clears cached RHDH versions (useful for tests) + */ +export function clearRhdhVersionCache(): void { + cachedRhdhVersions = new Map(); +}