From 66352c293303131bb34bff0c12eb512b49a4a8bb Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:08:05 -0400 Subject: [PATCH 01/12] feat: add plugin check-versions command and RHDH version resolution engine (RHIDP-16665, RHIDP-16667) Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- .../rhidp-16665-rhidp-16667-check-versions.md | 8 + src/commands/check-versions/command.test.ts | 239 ++++++++++++++ src/commands/check-versions/command.ts | 254 +++++++++++++++ src/commands/check-versions/index.ts | 17 + src/commands/index.ts | 20 +- src/lib/backstageVersion.ts | 24 +- src/lib/rhdhVersion.test.ts | 293 +++++++++++++++++ src/lib/rhdhVersion.ts | 307 ++++++++++++++++++ 8 files changed, 1154 insertions(+), 8 deletions(-) create mode 100644 .changeset/rhidp-16665-rhidp-16667-check-versions.md create mode 100644 src/commands/check-versions/command.test.ts create mode 100644 src/commands/check-versions/command.ts create mode 100644 src/commands/check-versions/index.ts create mode 100644 src/lib/rhdhVersion.test.ts create mode 100644 src/lib/rhdhVersion.ts diff --git a/.changeset/rhidp-16665-rhidp-16667-check-versions.md b/.changeset/rhidp-16665-rhidp-16667-check-versions.md new file mode 100644 index 0000000..ff7ca14 --- /dev/null +++ b/.changeset/rhidp-16665-rhidp-16667-check-versions.md @@ -0,0 +1,8 @@ +--- +'@red-hat-developer-hub/cli': minor +--- + +Add RHDH version resolution engine and `rhdh-cli plugin check-versions` (alias `plugin versions:lint`) command (RHIDP-16665, RHIDP-16667). + +- Resolves RHDH release versions to Backstage release manifests using a 3-tier resolution strategy (remote GitHub build-metadata, embedded static compatibility matrix fallback, and Backstage release manifests). +- Adds `rhdh-cli plugin check-versions` (alias `rhdh-cli plugin versions:lint`) to audit `@backstage/*` dependencies in `package.json` against the target RHDH release manifest, with support for human-readable tabular output, JSON output, and offline mode. diff --git a/src/commands/check-versions/command.test.ts b/src/commands/check-versions/command.test.ts new file mode 100644 index 0000000..6b79a91 --- /dev/null +++ b/src/commands/check-versions/command.test.ts @@ -0,0 +1,239 @@ +/* + * 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 'path'; +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 + >; + + 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 () => { + const pkgJson = { + name: 'test-plugin', + dependencies: { + '@backstage/core-plugin-api': '^1.12.0', + '@backstage/catalog-model': '~1.7.6', + }, + devDependencies: { + '@backstage/cli': '0.36.3', + }, + peerDependencies: { + '@backstage/config': 'backstage:^', + }, + }; + await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); + + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + source: 'matrix', + packages: new Map([ + ['@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(4); + expect(result.counts.mismatched).toBe(0); + expect(result.counts.unmanifested).toBe(0); + }); + + it('reports mismatched and unmanifested dependencies when versions differ', async () => { + const pkgJson = { + name: 'test-plugin', + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', // Mismatched (expected 1.12.0) + '@backstage/unknown-pkg': '^1.0.0', // Unmanifested + lodash: '^4.17.21', // Non-backstage: ignored + }, + devDependencies: { + '@backstage/cli': '^0.30.0', // Mismatched (expected 0.36.3) + }, + }; + await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); + + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + source: 'matrix', + packages: new Map([ + ['@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.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('outputs JSON when --json flag is passed and sets exitCode on failure', async () => { + const pkgJson = { + name: 'test-plugin', + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }; + await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); + + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + source: 'matrix', + packages: new Map([['@backstage/core-plugin-api', '1.12.0']]), + }); + + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + try { + await command({ json: true }); + + expect(stdoutSpy).toHaveBeenCalled(); + const jsonCall = stdoutSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(jsonCall); + expect(parsed.valid).toBe(false); + expect(parsed.counts.mismatched).toBe(1); + expect(process.exitCode).toBe(1); + } finally { + stdoutSpy.mockRestore(); + } + }); + + it('prints formatted table and remediation when run in human mode', async () => { + const pkgJson = { + name: 'test-plugin', + dependencies: { + '@backstage/core-plugin-api': '^1.9.0', + }, + }; + await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); + + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + source: 'matrix', + packages: new Map([['@backstage/core-plugin-api', '1.12.0']]), + }); + + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + try { + await command({}); + + expect(stderrSpy).toHaveBeenCalled(); + const output = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('Package'); + expect(output).toContain('@backstage/core-plugin-api'); + expect(output).toContain('mismatch'); + expect(output).toContain('rhdh-cli plugin upgrade 2.0.0'); + expect(process.exitCode).toBe(1); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('prints success message when dependencies are aligned', async () => { + const pkgJson = { + name: 'test-plugin', + dependencies: { + '@backstage/core-plugin-api': '^1.12.0', + }, + }; + await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); + + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + source: 'matrix', + packages: new Map([['@backstage/core-plugin-api', '1.12.0']]), + }); + + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + try { + await command({}); + + expect(stderrSpy).toHaveBeenCalled(); + const output = stderrSpy.mock.calls.map(c => c[0]).join(''); + expect(output).toContain('All @backstage dependencies are aligned'); + expect(process.exitCode).toBeUndefined(); + } finally { + stderrSpy.mockRestore(); + } + }); + }); +}); diff --git a/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts new file mode 100644 index 0000000..b5ff97b --- /dev/null +++ b/src/commands/check-versions/command.ts @@ -0,0 +1,254 @@ +/* + * 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 'path'; +import semver from 'semver'; +import { paths } from '../../lib/paths'; +import { resolveRhdhVersion } from '../../lib/rhdhVersion'; +import { Task } from '../../lib/tasks'; + +export type DependencyStatus = 'match' | 'mismatch' | 'unmanifested'; +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'; + valid: boolean; + counts: { + matching: number; + mismatched: number; + unmanifested: number; + total: number; + }; + packages: PackageCheckResult[]; +} + +export interface CheckVersionsOptions { + rhdhVersion?: string; + manifestFile?: string; + json?: boolean; + targetDir?: string; +} + +/** + * 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)) { + // Only audit @backstage/* packages or packages declared in the Backstage manifest + const isBackstagePkg = name.startsWith('@backstage/'); + const expectedVersion = resolved.packages.get(name); + + if (!isBackstagePkg && !expectedVersion) { + continue; + } + + if (!expectedVersion) { + packages.push({ + name, + section, + declared: declaredVersion, + status: 'unmanifested', + }); + continue; + } + + // Check version matching + const isMatch = isVersionAligned(declaredVersion, expectedVersion); + + packages.push({ + name, + section, + declared: declaredVersion, + expected: expectedVersion, + status: isMatch ? 'match' : 'mismatch', + }); + } + } + + 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 valid = mismatched === 0 && unmanifested === 0; + + return { + rhdhVersion: resolved.rhdhVersion, + backstageVersion: resolved.backstageVersion, + source: resolved.source, + valid, + counts: { + matching, + mismatched, + unmanifested, + total: packages.length, + }, + packages, + }; +} + +/** + * Determines if a declared version string is aligned with the manifest expected version + */ +function isVersionAligned( + declaredVersion: string, + expectedVersion: string, +): boolean { + if (declaredVersion === 'backstage:^') { + return true; + } + + const cleanedDeclared = declaredVersion.replace(/^[\^~]/, ''); + if (cleanedDeclared === expectedVersion) { + return true; + } + + const parsedDeclared = semver.clean(declaredVersion); + if (parsedDeclared === expectedVersion) { + return true; + } + + return false; +} + +/** + * 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, + json, + }); + + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.valid) { + process.exitCode = 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 { + 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 + const summary = `${chalk.green(`✓ ${result.counts.matching} matching`)}, ${chalk.red(`✗ ${result.counts.mismatched} mismatched`)}, ${chalk.yellow(`⚠ ${result.counts.unmanifested} unmanifested`)} (${result.counts.total} total)`; + process.stderr.write(`${chalk.bold('Summary:')} ${summary}\n`); + + if (!result.valid) { + process.stderr.write( + `\n${chalk.yellow('Remediation:')} Run ${chalk.cyan(`rhdh-cli plugin upgrade ${result.rhdhVersion}`)} to align dependencies with RHDH v${result.rhdhVersion}.\n\n`, + ); + process.exitCode = 1; + } 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..3b5c784 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -146,7 +146,25 @@ export function registerPluginCommand(program: Command) { .action( lazy(() => import('./package-dynamic-plugins').then(m => m.command)), ); + + command + .command('check-versions') + .alias('versions:lint') + .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)', + ) + .option( + '--manifest-file ', + 'Path to local Backstage release manifest JSON file (for offline usage)', + ) + .option('--json', 'Output results as JSON') + .action(lazy(() => import('./check-versions').then(m => m.command))); } + export function registerCommands(program: Command) { registerPluginCommand(program); registerIntentCommands(program); @@ -161,7 +179,7 @@ function lazy( const actionFunc = await getActionFunc(); await actionFunc(...args); - process.exit(0); + process.exit(process.exitCode ?? 0); } catch (error) { assertError(error); exitWithError(error); diff --git a/src/lib/backstageVersion.ts b/src/lib/backstageVersion.ts index a76869e..cb985dd 100644 --- a/src/lib/backstageVersion.ts +++ b/src/lib/backstageVersion.ts @@ -83,17 +83,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 && + cachedManifest.version === backstageVersion && + !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 +119,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` + diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts new file mode 100644 index 0000000..428b89f --- /dev/null +++ b/src/lib/rhdhVersion.test.ts @@ -0,0 +1,293 @@ +/* + * 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 { clearManifestCache } from './backstageVersion'; +import { + clearRhdhVersionCache, + DEFAULT_RHDH_VERSION, + fetchRemoteRhdhMetadata, + findStaticMatrixBackstageVersion, + getRhdhGitRef, + getSupportedRhdhVersions, + normalizeRhdhVersion, + resolveRhdhVersion, +} from './rhdhVersion'; + +describe('rhdhVersion', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + clearRhdhVersionCache(); + clearManifestCache(); + delete process.env.RHDH_OFFLINE; + delete process.env.BACKSTAGE_MANIFEST_FILE; + delete process.env.BACKSTAGE_VERSIONS_BASE_URL; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('normalizeRhdhVersion', () => { + it('returns default version when no input provided', () => { + 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('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'); + }); + }); + + 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.52.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 () => { + const mockMetadata = { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }; + + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockMetadata, + } as any); + + 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('handles HTTP error gracefully by returning undefined', async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + } as any); + + const result = await fetchRemoteRhdhMetadata('9.9.9'); + expect(result).toBeUndefined(); + }); + + it('handles network failure / fetch exception gracefully', async () => { + globalThis.fetch = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + const result = await fetchRemoteRhdhMetadata('2.0.0'); + expect(result).toBeUndefined(); + }); + }); + + describe('resolveRhdhVersion', () => { + it('resolves remote metadata when available (Tier 1)', async () => { + globalThis.fetch = jest.fn().mockImplementation((url: string) => { + if (url.includes('build-metadata.json')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }), + }); + } + if (url.includes('manifest.json')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + releaseVersion: '1.52.0', + packages: [ + { name: '@backstage/core-plugin-api', version: '1.12.0' }, + ], + }), + }); + } + return Promise.reject(new Error(`Unexpected url: ${url}`)); + }); + + 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('falls back to static compatibility matrix (Tier 2) when remote fails', async () => { + globalThis.fetch = jest.fn().mockImplementation((url: string) => { + if (url.includes('build-metadata.json')) { + return Promise.reject(new Error('Network unreachable')); + } + if (url.includes('manifest.json')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + releaseVersion: '1.45.3', + packages: [ + { name: '@backstage/core-plugin-api', version: '1.10.9' }, + ], + }), + }); + } + return Promise.reject(new Error(`Unexpected url: ${url}`)); + }); + + 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 = jest.fn().mockImplementation((url: string) => { + if (url.includes('manifest.json')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + releaseVersion: '1.52.0', + packages: [], + }), + }); + } + return Promise.reject(new Error('Should not be called')); + }); + globalThis.fetch = fetchMock; + + 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('throws descriptive error on unknown RHDH version', async () => { + globalThis.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + } as any); + + 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 = jest.fn().mockImplementation((url: string) => { + if (url.includes('build-metadata.json')) { + return Promise.resolve({ + ok: true, + json: async () => ({ + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }), + }); + } + if (url.includes('manifest.json')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + releaseVersion: '1.52.0', + packages: [], + }), + }); + } + return Promise.reject(new Error(`Unexpected url: ${url}`)); + }); + globalThis.fetch = fetchMock; + + const res1 = await resolveRhdhVersion('2.0.0'); + const res2 = await resolveRhdhVersion('2.0.0'); + + expect(res1).toBe(res2); + // Fetch for build-metadata and manifest should each only have been called once + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts new file mode 100644 index 0000000..eba643a --- /dev/null +++ b/src/lib/rhdhVersion.ts @@ -0,0 +1,307 @@ +/* + * 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/air-gapped operations and as a fallback when remote metadata lookup is unavailable. + */ +export const RHDH_COMPATIBILITY_MATRIX: Record = { + '2.1.0': '1.52.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.52.0', + next: '1.52.0', +}; + +/** + * Default stable RHDH GA release version + */ +export const DEFAULT_RHDH_VERSION = '2.0.0'; + +export type RhdhVersionSource = 'remote' | 'matrix'; + +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'; + } + + // Strip leading 'v' or 'v.' + const clean = trimmed.replace(/^v\.?/, ''); + + return clean; +} + +/** + * Maps an RHDH version or branch name to a GitHub repository ref/branch in redhat-developer/rhdh + */ +export function getRhdhGitRef(version: string): string { + if (version === 'main' || version === 'next') { + return 'main'; + } + + // 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 target RHDH repository release branch + */ +export async function fetchRemoteRhdhMetadata( + rhdhVersion: string, + options?: { timeoutMs?: number; baseUrl?: string }, +): Promise<{ rhdhVersion: string; backstageVersion: string } | undefined> { + const gitRef = getRhdhGitRef(rhdhVersion); + 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) { + return undefined; + } + + 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) || bsVersion.trim(); + return { + rhdhVersion: resolvedRhdhVersion, + backstageVersion: validBsVersion, + }; + } + } catch { + // Network error, abort timeout, or invalid JSON: fall back to matrix + return undefined; + } 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 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 { + let targetVersion = rhdhVersionInput; + + // If no version specified, try backstage.json in current project first, else default + if (!targetVersion) { + const currentBsVersion = await getCurrentBackstageVersion(); + if (currentBsVersion) { + // Check if current backstage.json version matches any known RHDH version in matrix + for (const [rVer, bsVer] of Object.entries(RHDH_COMPATIBILITY_MATRIX)) { + if (bsVer === currentBsVersion && rVer !== 'main' && rVer !== 'next') { + targetVersion = rVer; + break; + } + } + } + } + + const normalized = normalizeRhdhVersion(targetVersion); + const cacheKey = `${normalized}:${options?.manifestFile || ''}:${options?.offline || ''}`; + + if (cachedRhdhVersions.has(cacheKey)) { + return cachedRhdhVersions.get(cacheKey)!; + } + + let backstageVersion: string | undefined; + let source: RhdhVersionSource = 'matrix'; + let resolvedRhdhVersion = normalized; + + const isOffline = + options?.offline || + process.env.RHDH_OFFLINE === 'true' || + Boolean(options?.manifestFile || process.env.BACKSTAGE_MANIFEST_FILE); + + // Tier 1: Try remote lookup if online + if (!isOffline) { + const remote = await fetchRemoteRhdhMetadata(normalized); + if (remote) { + backstageVersion = remote.backstageVersion; + resolvedRhdhVersion = remote.rhdhVersion; + source = 'remote'; + } + } + + // Tier 2: Static compatibility matrix fallback + if (!backstageVersion) { + backstageVersion = findStaticMatrixBackstageVersion(normalized); + source = 'matrix'; + } + + if (!backstageVersion) { + const supported = getSupportedRhdhVersions().join(', '); + throw new Error( + `Unsupported or unknown RHDH version "${rhdhVersionInput}". ` + + `Supported versions are: ${supported} (or 'latest', 'next', 'main').`, + ); + } + + // Tier 3: Fetch Backstage release manifest + const packages = await getBackstageManifest(backstageVersion, { + manifestFile: options?.manifestFile, + versionsBaseUrl: options?.versionsBaseUrl, + }); + + const result: ResolvedRhdhVersion = { + rhdhVersion: resolvedRhdhVersion, + backstageVersion, + packages, + source, + }; + + cachedRhdhVersions.set(cacheKey, result); + return result; +} + +/** + * Clears cached RHDH versions (useful for tests) + */ +export function clearRhdhVersionCache(): void { + cachedRhdhVersions = new Map(); +} From 5b3e492dd4b20e0005088a3bfb3d6d94e3f8060a Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:12:42 -0400 Subject: [PATCH 02/12] refactor: address SonarCloud maintainability and duplication findings - Use node:path instead of path - Reduce cognitive complexity in checkPluginDependencies and resolveRhdhVersion - Remove nested template literals in linter output - Use optional chaining for cached manifest - Deduplicate test setup fixtures in check-versions unit tests Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/check-versions/command.test.ts | 115 +++++++----------- src/commands/check-versions/command.ts | 128 +++++++++++--------- src/lib/backstageVersion.ts | 6 +- src/lib/rhdhVersion.ts | 111 ++++++++++------- 4 files changed, 186 insertions(+), 174 deletions(-) diff --git a/src/commands/check-versions/command.test.ts b/src/commands/check-versions/command.test.ts index 6b79a91..a714fe1 100644 --- a/src/commands/check-versions/command.test.ts +++ b/src/commands/check-versions/command.test.ts @@ -16,7 +16,7 @@ import fs from 'fs-extra'; import os from 'os'; -import path from 'path'; +import path from 'node:path'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; import { checkPluginDependencies, command } from './command'; @@ -32,6 +32,29 @@ describe('checkPluginDependencies', () => { typeof resolveRhdhVersion >; + async function writeTestPackageJson( + dir: string, + sections: { + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + }, + ) { + await fs.writeJson(path.join(dir, 'package.json'), { + name: 'test-plugin', + ...sections, + }); + } + + function mockResolution(packages: [string, string][]) { + mockResolveRhdhVersion.mockResolvedValue({ + rhdhVersion: '2.0.0', + backstageVersion: '1.52.0', + source: 'matrix', + packages: new Map(packages), + }); + } + beforeEach(async () => { originalCwd = process.cwd(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'check-versions-test-')); @@ -53,8 +76,7 @@ describe('checkPluginDependencies', () => { }); it('reports matching dependencies when versions align with manifest', async () => { - const pkgJson = { - name: 'test-plugin', + await writeTestPackageJson(tmpDir, { dependencies: { '@backstage/core-plugin-api': '^1.12.0', '@backstage/catalog-model': '~1.7.6', @@ -65,21 +87,15 @@ describe('checkPluginDependencies', () => { peerDependencies: { '@backstage/config': 'backstage:^', }, - }; - await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); - - mockResolveRhdhVersion.mockResolvedValue({ - rhdhVersion: '2.0.0', - backstageVersion: '1.52.0', - source: 'matrix', - packages: new Map([ - ['@backstage/core-plugin-api', '1.12.0'], - ['@backstage/catalog-model', '1.7.6'], - ['@backstage/cli', '0.36.3'], - ['@backstage/config', '1.3.8'], - ]), }); + mockResolution([ + ['@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); @@ -89,8 +105,7 @@ describe('checkPluginDependencies', () => { }); it('reports mismatched and unmanifested dependencies when versions differ', async () => { - const pkgJson = { - name: 'test-plugin', + await writeTestPackageJson(tmpDir, { dependencies: { '@backstage/core-plugin-api': '^1.9.0', // Mismatched (expected 1.12.0) '@backstage/unknown-pkg': '^1.0.0', // Unmanifested @@ -99,19 +114,13 @@ describe('checkPluginDependencies', () => { devDependencies: { '@backstage/cli': '^0.30.0', // Mismatched (expected 0.36.3) }, - }; - await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); - - mockResolveRhdhVersion.mockResolvedValue({ - rhdhVersion: '2.0.0', - backstageVersion: '1.52.0', - source: 'matrix', - packages: new Map([ - ['@backstage/core-plugin-api', '1.12.0'], - ['@backstage/cli', '0.36.3'], - ]), }); + mockResolution([ + ['@backstage/core-plugin-api', '1.12.0'], + ['@backstage/cli', '0.36.3'], + ]); + const result = await checkPluginDependencies({ targetDir: tmpDir }); expect(result.valid).toBe(false); @@ -136,20 +145,10 @@ describe('checkPluginDependencies', () => { describe('CLI command handler', () => { it('outputs JSON when --json flag is passed and sets exitCode on failure', async () => { - const pkgJson = { - name: 'test-plugin', - dependencies: { - '@backstage/core-plugin-api': '^1.9.0', - }, - }; - await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); - - mockResolveRhdhVersion.mockResolvedValue({ - rhdhVersion: '2.0.0', - backstageVersion: '1.52.0', - source: 'matrix', - packages: new Map([['@backstage/core-plugin-api', '1.12.0']]), + await writeTestPackageJson(tmpDir, { + dependencies: { '@backstage/core-plugin-api': '^1.9.0' }, }); + mockResolution([['@backstage/core-plugin-api', '1.12.0']]); const stdoutSpy = jest .spyOn(process.stdout, 'write') @@ -170,20 +169,10 @@ describe('checkPluginDependencies', () => { }); it('prints formatted table and remediation when run in human mode', async () => { - const pkgJson = { - name: 'test-plugin', - dependencies: { - '@backstage/core-plugin-api': '^1.9.0', - }, - }; - await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); - - mockResolveRhdhVersion.mockResolvedValue({ - rhdhVersion: '2.0.0', - backstageVersion: '1.52.0', - source: 'matrix', - packages: new Map([['@backstage/core-plugin-api', '1.12.0']]), + await writeTestPackageJson(tmpDir, { + dependencies: { '@backstage/core-plugin-api': '^1.9.0' }, }); + mockResolution([['@backstage/core-plugin-api', '1.12.0']]); const stderrSpy = jest .spyOn(process.stderr, 'write') @@ -205,20 +194,10 @@ describe('checkPluginDependencies', () => { }); it('prints success message when dependencies are aligned', async () => { - const pkgJson = { - name: 'test-plugin', - dependencies: { - '@backstage/core-plugin-api': '^1.12.0', - }, - }; - await fs.writeJson(path.join(tmpDir, 'package.json'), pkgJson); - - mockResolveRhdhVersion.mockResolvedValue({ - rhdhVersion: '2.0.0', - backstageVersion: '1.52.0', - source: 'matrix', - packages: new Map([['@backstage/core-plugin-api', '1.12.0']]), + await writeTestPackageJson(tmpDir, { + dependencies: { '@backstage/core-plugin-api': '^1.12.0' }, }); + mockResolution([['@backstage/core-plugin-api', '1.12.0']]); const stderrSpy = jest .spyOn(process.stderr, 'write') diff --git a/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts index b5ff97b..5107c9d 100644 --- a/src/commands/check-versions/command.ts +++ b/src/commands/check-versions/command.ts @@ -17,7 +17,7 @@ import chalk from 'chalk'; import { OptionValues } from 'commander'; import fs from 'fs-extra'; -import path from 'path'; +import path from 'node:path'; import semver from 'semver'; import { paths } from '../../lib/paths'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; @@ -58,6 +58,61 @@ export interface CheckVersionsOptions { targetDir?: string; } +/** + * Determines if a declared version string is aligned with the manifest expected version + */ +function isVersionAligned( + declaredVersion: string, + expectedVersion: string, +): boolean { + if (declaredVersion === 'backstage:^') { + return true; + } + + 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', + }; + } + + 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 */ @@ -91,34 +146,15 @@ export async function checkPluginDependencies( if (!deps) continue; for (const [name, declaredVersion] of Object.entries(deps)) { - // Only audit @backstage/* packages or packages declared in the Backstage manifest - const isBackstagePkg = name.startsWith('@backstage/'); - const expectedVersion = resolved.packages.get(name); - - if (!isBackstagePkg && !expectedVersion) { - continue; - } - - if (!expectedVersion) { - packages.push({ - name, - section, - declared: declaredVersion, - status: 'unmanifested', - }); - continue; - } - - // Check version matching - const isMatch = isVersionAligned(declaredVersion, expectedVersion); - - packages.push({ + const audited = auditDependency( name, + declaredVersion, section, - declared: declaredVersion, - expected: expectedVersion, - status: isMatch ? 'match' : 'mismatch', - }); + resolved.packages, + ); + if (audited) { + packages.push(audited); + } } } @@ -142,30 +178,6 @@ export async function checkPluginDependencies( }; } -/** - * Determines if a declared version string is aligned with the manifest expected version - */ -function isVersionAligned( - declaredVersion: string, - expectedVersion: string, -): boolean { - if (declaredVersion === 'backstage:^') { - return true; - } - - const cleanedDeclared = declaredVersion.replace(/^[\^~]/, ''); - if (cleanedDeclared === expectedVersion) { - return true; - } - - const parsedDeclared = semver.clean(declaredVersion); - if (parsedDeclared === expectedVersion) { - return true; - } - - return false; -} - /** * CLI command entry point for `rhdh-cli plugin check-versions` */ @@ -237,13 +249,21 @@ export async function command(opts: OptionValues): Promise { process.stderr.write('\n'); - // Summary line - const summary = `${chalk.green(`✓ ${result.counts.matching} matching`)}, ${chalk.red(`✗ ${result.counts.mismatched} mismatched`)}, ${chalk.yellow(`⚠ ${result.counts.unmanifested} unmanifested`)} (${result.counts.total} total)`; + // 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 summary = `${matchStr}, ${mismatchStr}, ${unmanifestedStr} (${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 ${chalk.cyan(`rhdh-cli plugin upgrade ${result.rhdhVersion}`)} to align dependencies with RHDH v${result.rhdhVersion}.\n\n`, + `\n${chalk.yellow('Remediation:')} Run ${upgradeCmd} to align dependencies with RHDH v${result.rhdhVersion}.\n\n`, ); process.exitCode = 1; } else { diff --git a/src/lib/backstageVersion.ts b/src/lib/backstageVersion.ts index cb985dd..57a9e34 100644 --- a/src/lib/backstageVersion.ts +++ b/src/lib/backstageVersion.ts @@ -95,11 +95,7 @@ export async function getBackstageManifest( const versionsBaseUrl = options?.versionsBaseUrl || process.env.BACKSTAGE_VERSIONS_BASE_URL; - if ( - cachedManifest && - cachedManifest.version === backstageVersion && - !manifestFile - ) { + if (cachedManifest?.version === backstageVersion && !manifestFile) { return cachedManifest.packages; } diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts index eba643a..b7b81d0 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -81,9 +81,7 @@ export function normalizeRhdhVersion(input?: string): string { } // Strip leading 'v' or 'v.' - const clean = trimmed.replace(/^v\.?/, ''); - - return clean; + return trimmed.replace(/^v\.?/, ''); } /** @@ -214,6 +212,59 @@ export function getSupportedRhdhVersions(): string[] { }); } +/** + * 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 remote metadata or static compatibility matrix + */ +async function resolveBackstageVersionForRhdh( + normalized: string, + isOffline: boolean, +): Promise< + | { + backstageVersion: string; + resolvedRhdhVersion: string; + source: RhdhVersionSource; + } + | undefined +> { + 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. * @@ -226,55 +277,22 @@ export async function resolveRhdhVersion( rhdhVersionInput?: string, options?: ResolveRhdhVersionOptions, ): Promise { - let targetVersion = rhdhVersionInput; - - // If no version specified, try backstage.json in current project first, else default - if (!targetVersion) { - const currentBsVersion = await getCurrentBackstageVersion(); - if (currentBsVersion) { - // Check if current backstage.json version matches any known RHDH version in matrix - for (const [rVer, bsVer] of Object.entries(RHDH_COMPATIBILITY_MATRIX)) { - if (bsVer === currentBsVersion && rVer !== 'main' && rVer !== 'next') { - targetVersion = rVer; - break; - } - } - } - } - + const targetVersion = rhdhVersionInput || (await getDefaultTargetVersion()); const normalized = normalizeRhdhVersion(targetVersion); const cacheKey = `${normalized}:${options?.manifestFile || ''}:${options?.offline || ''}`; - if (cachedRhdhVersions.has(cacheKey)) { - return cachedRhdhVersions.get(cacheKey)!; + const cached = cachedRhdhVersions.get(cacheKey); + if (cached) { + return cached; } - let backstageVersion: string | undefined; - let source: RhdhVersionSource = 'matrix'; - let resolvedRhdhVersion = normalized; - const isOffline = options?.offline || process.env.RHDH_OFFLINE === 'true' || Boolean(options?.manifestFile || process.env.BACKSTAGE_MANIFEST_FILE); - // Tier 1: Try remote lookup if online - if (!isOffline) { - const remote = await fetchRemoteRhdhMetadata(normalized); - if (remote) { - backstageVersion = remote.backstageVersion; - resolvedRhdhVersion = remote.rhdhVersion; - source = 'remote'; - } - } - - // Tier 2: Static compatibility matrix fallback - if (!backstageVersion) { - backstageVersion = findStaticMatrixBackstageVersion(normalized); - source = 'matrix'; - } - - if (!backstageVersion) { + const resolved = await resolveBackstageVersionForRhdh(normalized, isOffline); + if (!resolved) { const supported = getSupportedRhdhVersions().join(', '); throw new Error( `Unsupported or unknown RHDH version "${rhdhVersionInput}". ` + @@ -282,17 +300,16 @@ export async function resolveRhdhVersion( ); } - // Tier 3: Fetch Backstage release manifest - const packages = await getBackstageManifest(backstageVersion, { + const packages = await getBackstageManifest(resolved.backstageVersion, { manifestFile: options?.manifestFile, versionsBaseUrl: options?.versionsBaseUrl, }); const result: ResolvedRhdhVersion = { - rhdhVersion: resolvedRhdhVersion, - backstageVersion, + rhdhVersion: resolved.resolvedRhdhVersion, + backstageVersion: resolved.backstageVersion, packages, - source, + source: resolved.source, }; cachedRhdhVersions.set(cacheKey, result); From 3bd97a3619ed3a4b9dd7d78c9a0d6e3ad3067f41 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:15:27 -0400 Subject: [PATCH 03/12] chore: bump version to 2.0.5 and update changelog Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- .changeset/rhidp-16665-rhidp-16667-check-versions.md | 8 -------- CHANGELOG.md | 6 ++++++ package.json | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) delete mode 100644 .changeset/rhidp-16665-rhidp-16667-check-versions.md diff --git a/.changeset/rhidp-16665-rhidp-16667-check-versions.md b/.changeset/rhidp-16665-rhidp-16667-check-versions.md deleted file mode 100644 index ff7ca14..0000000 --- a/.changeset/rhidp-16665-rhidp-16667-check-versions.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@red-hat-developer-hub/cli': minor ---- - -Add RHDH version resolution engine and `rhdh-cli plugin check-versions` (alias `plugin versions:lint`) command (RHIDP-16665, RHIDP-16667). - -- Resolves RHDH release versions to Backstage release manifests using a 3-tier resolution strategy (remote GitHub build-metadata, embedded static compatibility matrix fallback, and Backstage release manifests). -- Adds `rhdh-cli plugin check-versions` (alias `rhdh-cli plugin versions:lint`) to audit `@backstage/*` dependencies in `package.json` against the target RHDH release manifest, with support for human-readable tabular output, JSON output, and offline mode. 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/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" }, From 9ffac3adfee543f88c1fe40ef9f30b132f33e999 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:17:55 -0400 Subject: [PATCH 04/12] test: deduplicate test setup helpers in unit test suites Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/lib/rhdhVersion.test.ts | 175 +++++++++++++++--------------------- 1 file changed, 74 insertions(+), 101 deletions(-) diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts index 428b89f..281d25d 100644 --- a/src/lib/rhdhVersion.test.ts +++ b/src/lib/rhdhVersion.test.ts @@ -29,6 +29,48 @@ import { describe('rhdhVersion', () => { const originalFetch = globalThis.fetch; + 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(); @@ -110,17 +152,14 @@ describe('rhdhVersion', () => { describe('fetchRemoteRhdhMetadata', () => { it('fetches and parses remote build-metadata.json successfully', async () => { - const mockMetadata = { - card: { - 'RHDH Version': '2.0.0', - 'Backstage Version': '1.52.0', + setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, }, - }; - - globalThis.fetch = jest.fn().mockResolvedValue({ - ok: true, - json: async () => mockMetadata, - } as any); + }); const result = await fetchRemoteRhdhMetadata('2.0.0'); expect(result).toEqual({ @@ -134,19 +173,14 @@ describe('rhdhVersion', () => { }); it('handles HTTP error gracefully by returning undefined', async () => { - globalThis.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 404, - } as any); + setupFetchMock({}); const result = await fetchRemoteRhdhMetadata('9.9.9'); expect(result).toBeUndefined(); }); it('handles network failure / fetch exception gracefully', async () => { - globalThis.fetch = jest - .fn() - .mockRejectedValue(new Error('Network error')); + setupFetchMock({ metadataError: new Error('Network error') }); const result = await fetchRemoteRhdhMetadata('2.0.0'); expect(result).toBeUndefined(); @@ -155,31 +189,14 @@ describe('rhdhVersion', () => { describe('resolveRhdhVersion', () => { it('resolves remote metadata when available (Tier 1)', async () => { - globalThis.fetch = jest.fn().mockImplementation((url: string) => { - if (url.includes('build-metadata.json')) { - return Promise.resolve({ - ok: true, - json: async () => ({ - card: { - 'RHDH Version': '2.0.0', - 'Backstage Version': '1.52.0', - }, - }), - }); - } - if (url.includes('manifest.json')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - releaseVersion: '1.52.0', - packages: [ - { name: '@backstage/core-plugin-api', version: '1.12.0' }, - ], - }), - }); - } - return Promise.reject(new Error(`Unexpected url: ${url}`)); + 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'); @@ -192,23 +209,10 @@ describe('rhdhVersion', () => { }); it('falls back to static compatibility matrix (Tier 2) when remote fails', async () => { - globalThis.fetch = jest.fn().mockImplementation((url: string) => { - if (url.includes('build-metadata.json')) { - return Promise.reject(new Error('Network unreachable')); - } - if (url.includes('manifest.json')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - releaseVersion: '1.45.3', - packages: [ - { name: '@backstage/core-plugin-api', version: '1.10.9' }, - ], - }), - }); - } - return Promise.reject(new Error(`Unexpected url: ${url}`)); + 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'); @@ -221,20 +225,10 @@ describe('rhdhVersion', () => { }); it('skips remote lookup when offline option is provided', async () => { - const fetchMock = jest.fn().mockImplementation((url: string) => { - if (url.includes('manifest.json')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - releaseVersion: '1.52.0', - packages: [], - }), - }); - } - return Promise.reject(new Error('Should not be called')); + const fetchMock = setupFetchMock({ + manifestVersion: '1.52.0', + packages: [], }); - globalThis.fetch = fetchMock; const resolved = await resolveRhdhVersion('2.0.0', { offline: true }); expect(resolved.source).toBe('matrix'); @@ -245,10 +239,7 @@ describe('rhdhVersion', () => { }); it('throws descriptive error on unknown RHDH version', async () => { - globalThis.fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 404, - } as any); + setupFetchMock({}); await expect(resolveRhdhVersion('999.0.0')).rejects.toThrow( /Unsupported or unknown RHDH version "999.0.0"/, @@ -256,37 +247,19 @@ describe('rhdhVersion', () => { }); it('caches resolution results on consecutive calls', async () => { - const fetchMock = jest.fn().mockImplementation((url: string) => { - if (url.includes('build-metadata.json')) { - return Promise.resolve({ - ok: true, - json: async () => ({ - card: { - 'RHDH Version': '2.0.0', - 'Backstage Version': '1.52.0', - }, - }), - }); - } - if (url.includes('manifest.json')) { - return Promise.resolve({ - ok: true, - status: 200, - json: async () => ({ - releaseVersion: '1.52.0', - packages: [], - }), - }); - } - return Promise.reject(new Error(`Unexpected url: ${url}`)); + const fetchMock = setupFetchMock({ + metadata: { + card: { + 'RHDH Version': '2.0.0', + 'Backstage Version': '1.52.0', + }, + }, }); - globalThis.fetch = fetchMock; const res1 = await resolveRhdhVersion('2.0.0'); const res2 = await resolveRhdhVersion('2.0.0'); expect(res1).toBe(res2); - // Fetch for build-metadata and manifest should each only have been called once expect(fetchMock).toHaveBeenCalledTimes(2); }); }); From f84068adb2ad1135fb9e9bca9a8576df95b8ba90 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:20:13 -0400 Subject: [PATCH 05/12] test: streamline check-versions test setup to remove duplicated fixture blocks Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/check-versions/command.test.ts | 185 ++++++++++---------- 1 file changed, 88 insertions(+), 97 deletions(-) diff --git a/src/commands/check-versions/command.test.ts b/src/commands/check-versions/command.test.ts index a714fe1..b846d01 100644 --- a/src/commands/check-versions/command.test.ts +++ b/src/commands/check-versions/command.test.ts @@ -32,29 +32,54 @@ describe('checkPluginDependencies', () => { typeof resolveRhdhVersion >; - async function writeTestPackageJson( - dir: string, - sections: { + 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(dir, 'package.json'), { + await fs.writeJson(path.join(tmpDir, 'package.json'), { name: 'test-plugin', - ...sections, + ...pkg, }); - } - - function mockResolution(packages: [string, string][]) { mockResolveRhdhVersion.mockResolvedValue({ rhdhVersion: '2.0.0', backstageVersion: '1.52.0', source: 'matrix', - packages: new Map(packages), + packages: new Map(manifestPackages), }); } + async function runCommandWithOutput(opts: any = {}) { + let stdout = ''; + let stderr = ''; + 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); + } finally { + stdoutSpy.mockRestore(); + stderrSpy.mockRestore(); + } + + return { stdout, stderr, exitCode: process.exitCode }; + } + beforeEach(async () => { originalCwd = process.cwd(); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'check-versions-test-')); @@ -76,25 +101,26 @@ describe('checkPluginDependencies', () => { }); it('reports matching dependencies when versions align with manifest', async () => { - await writeTestPackageJson(tmpDir, { - dependencies: { - '@backstage/core-plugin-api': '^1.12.0', - '@backstage/catalog-model': '~1.7.6', + 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:^', + }, }, - devDependencies: { - '@backstage/cli': '0.36.3', - }, - peerDependencies: { - '@backstage/config': 'backstage:^', - }, - }); - - mockResolution([ - ['@backstage/core-plugin-api', '1.12.0'], - ['@backstage/catalog-model', '1.7.6'], - ['@backstage/cli', '0.36.3'], - ['@backstage/config', '1.3.8'], - ]); + [ + ['@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 }); @@ -105,21 +131,22 @@ describe('checkPluginDependencies', () => { }); it('reports mismatched and unmanifested dependencies when versions differ', async () => { - await writeTestPackageJson(tmpDir, { - dependencies: { - '@backstage/core-plugin-api': '^1.9.0', // Mismatched (expected 1.12.0) - '@backstage/unknown-pkg': '^1.0.0', // Unmanifested - lodash: '^4.17.21', // Non-backstage: ignored + 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', + }, }, - devDependencies: { - '@backstage/cli': '^0.30.0', // Mismatched (expected 0.36.3) - }, - }); - - mockResolution([ - ['@backstage/core-plugin-api', '1.12.0'], - ['@backstage/cli', '0.36.3'], - ]); + [ + ['@backstage/core-plugin-api', '1.12.0'], + ['@backstage/cli', '0.36.3'], + ], + ); const result = await checkPluginDependencies({ targetDir: tmpDir }); @@ -145,74 +172,38 @@ describe('checkPluginDependencies', () => { describe('CLI command handler', () => { it('outputs JSON when --json flag is passed and sets exitCode on failure', async () => { - await writeTestPackageJson(tmpDir, { + await setupFixture({ dependencies: { '@backstage/core-plugin-api': '^1.9.0' }, }); - mockResolution([['@backstage/core-plugin-api', '1.12.0']]); - - const stdoutSpy = jest - .spyOn(process.stdout, 'write') - .mockImplementation(() => true); - - try { - await command({ json: true }); - - expect(stdoutSpy).toHaveBeenCalled(); - const jsonCall = stdoutSpy.mock.calls[0][0] as string; - const parsed = JSON.parse(jsonCall); - expect(parsed.valid).toBe(false); - expect(parsed.counts.mismatched).toBe(1); - expect(process.exitCode).toBe(1); - } finally { - stdoutSpy.mockRestore(); - } + + 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.exitCode).toBe(1); }); it('prints formatted table and remediation when run in human mode', async () => { - await writeTestPackageJson(tmpDir, { + await setupFixture({ dependencies: { '@backstage/core-plugin-api': '^1.9.0' }, }); - mockResolution([['@backstage/core-plugin-api', '1.12.0']]); - - const stderrSpy = jest - .spyOn(process.stderr, 'write') - .mockImplementation(() => true); - - try { - await command({}); - - expect(stderrSpy).toHaveBeenCalled(); - const output = stderrSpy.mock.calls.map(c => c[0]).join(''); - expect(output).toContain('Package'); - expect(output).toContain('@backstage/core-plugin-api'); - expect(output).toContain('mismatch'); - expect(output).toContain('rhdh-cli plugin upgrade 2.0.0'); - expect(process.exitCode).toBe(1); - } finally { - stderrSpy.mockRestore(); - } + + 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.exitCode).toBe(1); }); it('prints success message when dependencies are aligned', async () => { - await writeTestPackageJson(tmpDir, { + await setupFixture({ dependencies: { '@backstage/core-plugin-api': '^1.12.0' }, }); - mockResolution([['@backstage/core-plugin-api', '1.12.0']]); - - const stderrSpy = jest - .spyOn(process.stderr, 'write') - .mockImplementation(() => true); - - try { - await command({}); - - expect(stderrSpy).toHaveBeenCalled(); - const output = stderrSpy.mock.calls.map(c => c[0]).join(''); - expect(output).toContain('All @backstage dependencies are aligned'); - expect(process.exitCode).toBeUndefined(); - } finally { - stderrSpy.mockRestore(); - } + + const res = await runCommandWithOutput({}); + expect(res.stderr).toContain('All @backstage dependencies are aligned'); + expect(res.exitCode).toBeUndefined(); }); }); }); From f9bb96a305afb4d5df6f816816a31be9b72d8ccd Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:46:13 -0400 Subject: [PATCH 06/12] feat: support in-development releases and direct backstage versions - Fall back to main branch when targeting in-development releases before branch cuts - Support direct Backstage version specifiers (e.g. backstage:1.54.0 or 1.54.0) - Update 2.1.0 and main compatibility matrix to Backstage 1.54.0 Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/lib/rhdhVersion.test.ts | 31 ++++++++- src/lib/rhdhVersion.ts | 125 +++++++++++++++++++++++------------- 2 files changed, 109 insertions(+), 47 deletions(-) diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts index 281d25d..293c354 100644 --- a/src/lib/rhdhVersion.test.ts +++ b/src/lib/rhdhVersion.test.ts @@ -99,6 +99,10 @@ describe('rhdhVersion', () => { 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'); @@ -125,7 +129,7 @@ describe('rhdhVersion', () => { 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.52.0'); + expect(findStaticMatrixBackstageVersion('main')).toBe('1.54.0'); }); it('resolves minor versions without patch to matrix entry', () => { @@ -208,6 +212,31 @@ describe('rhdhVersion', () => { ); }); + 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.packages.get('@backstage/core-plugin-api')).toBe( + '1.14.0', + ); + }); + + it('resolves raw Backstage version string', async () => { + setupFetchMock({ + manifestVersion: '1.54.0', + packages: [{ name: '@backstage/core-plugin-api', version: '1.14.0' }], + }); + + const resolved = await resolveRhdhVersion('1.54.0'); + expect(resolved.backstageVersion).toBe('1.54.0'); + expect(resolved.rhdhVersion).toBe('backstage:1.54.0'); + }); + it('falls back to static compatibility matrix (Tier 2) when remote fails', async () => { setupFetchMock({ metadataError: new Error('Network unreachable'), diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts index b7b81d0..601f9cf 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -25,7 +25,7 @@ import { * Used for offline/air-gapped operations and as a fallback when remote metadata lookup is unavailable. */ export const RHDH_COMPATIBILITY_MATRIX: Record = { - '2.1.0': '1.52.0', + '2.1.0': '1.54.0', '2.0.4': '1.52.0', '2.0.0': '1.52.0', '1.10.0': '1.49.4', @@ -33,8 +33,8 @@ export const RHDH_COMPATIBILITY_MATRIX: Record = { '1.8.0': '1.42.5', '1.7.0': '1.39.1', '1.6.0': '1.36.1', - main: '1.52.0', - next: '1.52.0', + main: '1.54.0', + next: '1.54.0', }; /** @@ -80,6 +80,11 @@ export function normalizeRhdhVersion(input?: string): string { return 'main'; } + // Preserve explicit backstage: prefix + if (trimmed.startsWith('backstage:')) { + return trimmed; + } + // Strip leading 'v' or 'v.' return trimmed.replace(/^v\.?/, ''); } @@ -102,7 +107,7 @@ export function getRhdhGitRef(version: string): string { } /** - * Fetches build-metadata.json from target RHDH repository release branch + * Fetches build-metadata.json from target RHDH repository release branch (falling back to main) */ export async function fetchRemoteRhdhMetadata( rhdhVersion: string, @@ -113,48 +118,50 @@ export async function fetchRemoteRhdhMetadata( 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) { - return undefined; - } - - 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) || bsVersion.trim(); - return { - rhdhVersion: resolvedRhdhVersion, - backstageVersion: validBsVersion, - }; + // If targeting a release branch that hasn't been cut yet, candidate fallbacks check main + const candidateRefs = gitRef === 'main' ? ['main'] : [gitRef, 'main']; + + for (const ref of candidateRefs) { + const metadataUrl = `${baseUrl}/${ref}/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) || bsVersion.trim(); + return { + rhdhVersion: resolvedRhdhVersion, + backstageVersion: validBsVersion, + }; + } + } + } catch { + // Try next candidate ref or fall through + } finally { + clearTimeout(timeoutId); } - } catch { - // Network error, abort timeout, or invalid JSON: fall back to matrix - return undefined; - } finally { - clearTimeout(timeoutId); } return undefined; @@ -229,7 +236,7 @@ async function getDefaultTargetVersion(): Promise { } /** - * Resolves Backstage version using remote metadata or static compatibility matrix + * Resolves Backstage version using explicit version, remote metadata, or static matrix */ async function resolveBackstageVersionForRhdh( normalized: string, @@ -242,6 +249,32 @@ async function resolveBackstageVersionForRhdh( } | undefined > { + // Support explicit Backstage versions (e.g. "backstage:1.54.0" or "1.54.0") + if (normalized.startsWith('backstage:')) { + const bsVer = normalized.replace(/^backstage:/, '').trim(); + if (semver.valid(bsVer)) { + return { + backstageVersion: bsVer, + resolvedRhdhVersion: `backstage:${bsVer}`, + source: 'matrix', + }; + } + } + + const parsed = semver.coerce(normalized); + if ( + parsed && + parsed.major === 1 && + parsed.minor >= 30 && + !RHDH_COMPATIBILITY_MATRIX[normalized] + ) { + return { + backstageVersion: normalized, + resolvedRhdhVersion: `backstage:${normalized}`, + source: 'matrix', + }; + } + if (!isOffline) { const remote = await fetchRemoteRhdhMetadata(normalized); if (remote) { @@ -296,7 +329,7 @@ export async function resolveRhdhVersion( const supported = getSupportedRhdhVersions().join(', '); throw new Error( `Unsupported or unknown RHDH version "${rhdhVersionInput}". ` + - `Supported versions are: ${supported} (or 'latest', 'next', 'main').`, + `Supported versions are: ${supported} (or 'latest', 'next', 'main', or direct 'backstage:').`, ); } From 4b0a14657f25af051ceb06445743ac9602724b36 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Fri, 4 Sep 2026 11:51:43 -0400 Subject: [PATCH 07/12] refactor: use optional chaining on parsed semver in rhdhVersion Assisted-by: opencode rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/lib/rhdhVersion.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts index 601f9cf..47c6ef4 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -263,8 +263,7 @@ async function resolveBackstageVersionForRhdh( const parsed = semver.coerce(normalized); if ( - parsed && - parsed.major === 1 && + parsed?.major === 1 && parsed.minor >= 30 && !RHDH_COMPATIBILITY_MATRIX[normalized] ) { From ea4e76d784cd6310ea3c064e621102022f533ff4 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Wed, 9 Sep 2026 06:26:19 -0400 Subject: [PATCH 08/12] fix: address check-versions review feedback Assisted-by: openai/gpt-5.6-terra rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- README.md | 12 +++ src/commands/check-versions/command.test.ts | 20 +++- src/commands/check-versions/command.ts | 36 +++++-- src/commands/index.ts | 4 +- src/lib/rhdhVersion.test.ts | 51 ++++++++-- src/lib/rhdhVersion.ts | 105 ++++++++------------ 6 files changed, 143 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index c2cbd32..f1efccb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,18 @@ 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. + ## Development ### Contributing diff --git a/src/commands/check-versions/command.test.ts b/src/commands/check-versions/command.test.ts index b846d01..8e9e94e 100644 --- a/src/commands/check-versions/command.test.ts +++ b/src/commands/check-versions/command.test.ts @@ -124,10 +124,14 @@ describe('checkPluginDependencies', () => { const result = await checkPluginDependencies({ targetDir: tmpDir }); - expect(result.valid).toBe(true); - expect(result.counts.matching).toBe(4); + expect(result.valid).toBe(false); + 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 () => { @@ -154,6 +158,7 @@ describe('checkPluginDependencies', () => { 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( @@ -171,6 +176,17 @@ describe('checkPluginDependencies', () => { }); 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.exitCode).toBe(1); + }); + it('outputs JSON when --json flag is passed and sets exitCode on failure', async () => { await setupFixture({ dependencies: { '@backstage/core-plugin-api': '^1.9.0' }, diff --git a/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts index 5107c9d..7414dab 100644 --- a/src/commands/check-versions/command.ts +++ b/src/commands/check-versions/command.ts @@ -23,7 +23,11 @@ import { paths } from '../../lib/paths'; import { resolveRhdhVersion } from '../../lib/rhdhVersion'; import { Task } from '../../lib/tasks'; -export type DependencyStatus = 'match' | 'mismatch' | 'unmanifested'; +export type DependencyStatus = + | 'match' + | 'mismatch' + | 'unmanifested' + | 'unverifiable'; export type DependencySection = | 'dependencies' | 'devDependencies' @@ -40,12 +44,13 @@ export interface PackageCheckResult { export interface CheckVersionsResult { rhdhVersion: string; backstageVersion: string; - source: 'remote' | 'matrix'; + source: 'remote' | 'matrix' | 'explicit'; valid: boolean; counts: { matching: number; mismatched: number; unmanifested: number; + unverifiable: number; total: number; }; packages: PackageCheckResult[]; @@ -59,16 +64,12 @@ export interface CheckVersionsOptions { } /** - * Determines if a declared version string is aligned with the manifest expected version + * Determines if a declared version string is aligned with the manifest expected version. */ function isVersionAligned( declaredVersion: string, expectedVersion: string, ): boolean { - if (declaredVersion === 'backstage:^') { - return true; - } - const cleanedDeclared = declaredVersion.replace(/^[\^~]/, ''); if (cleanedDeclared === expectedVersion) { return true; @@ -103,6 +104,16 @@ function auditDependency( }; } + if (declaredVersion === 'backstage:^') { + return { + name, + section, + declared: declaredVersion, + expected: expectedVersion, + status: 'unverifiable', + }; + } + const isMatch = isVersionAligned(declaredVersion, expectedVersion); return { name, @@ -161,7 +172,8 @@ export async function checkPluginDependencies( 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 valid = mismatched === 0 && unmanifested === 0; + const unverifiable = packages.filter(p => p.status === 'unverifiable').length; + const valid = mismatched === 0 && unmanifested === 0 && unverifiable === 0; return { rhdhVersion: resolved.rhdhVersion, @@ -172,6 +184,7 @@ export async function checkPluginDependencies( matching, mismatched, unmanifested, + unverifiable, total: packages.length, }, packages, @@ -239,6 +252,8 @@ export async function command(opts: OptionValues): Promise { 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'); } @@ -255,7 +270,10 @@ export async function command(opts: OptionValues): Promise { const unmanifestedStr = chalk.yellow( `⚠ ${result.counts.unmanifested} unmanifested`, ); - const summary = `${matchStr}, ${mismatchStr}, ${unmanifestedStr} (${result.counts.total} total)`; + 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) { diff --git a/src/commands/index.ts b/src/commands/index.ts index 3b5c784..7ae7e1e 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -155,11 +155,11 @@ export function registerPluginCommand(program: Command) { ) .option( '--rhdh-version ', - 'Target RHDH version to check compatibility against (e.g. 2.0.0, 1.9, latest)', + 'Target RHDH version to check compatibility against (e.g. 2.0.0, 1.9, latest, backstage:1.54.0)', ) .option( '--manifest-file ', - 'Path to local Backstage release manifest JSON file (for offline usage)', + '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))); diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts index 293c354..55cd1df 100644 --- a/src/lib/rhdhVersion.test.ts +++ b/src/lib/rhdhVersion.test.ts @@ -15,6 +15,9 @@ */ import { clearManifestCache } from './backstageVersion'; +import fs from 'fs-extra'; +import os from 'os'; +import path from 'node:path'; import { clearRhdhVersionCache, DEFAULT_RHDH_VERSION, @@ -176,11 +179,16 @@ describe('rhdhVersion', () => { ); }); - it('handles HTTP error gracefully by returning undefined', async () => { + 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 () => { @@ -221,20 +229,18 @@ describe('rhdhVersion', () => { 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('resolves raw Backstage version string', async () => { - setupFetchMock({ - manifestVersion: '1.54.0', - packages: [{ name: '@backstage/core-plugin-api', version: '1.14.0' }], - }); + it('rejects an ambiguous bare Backstage version', async () => { + setupFetchMock({}); - const resolved = await resolveRhdhVersion('1.54.0'); - expect(resolved.backstageVersion).toBe('1.54.0'); - expect(resolved.rhdhVersion).toBe('backstage:1.54.0'); + 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 () => { @@ -267,6 +273,33 @@ describe('rhdhVersion', () => { ); }); + 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('throws descriptive error on unknown RHDH version', async () => { setupFetchMock({}); diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts index 47c6ef4..bdd1f8f 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -22,7 +22,7 @@ import { /** * Static embedded compatibility matrix between RHDH releases and Backstage releases. - * Used for offline/air-gapped operations and as a fallback when remote metadata lookup is unavailable. + * 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', @@ -42,7 +42,7 @@ export const RHDH_COMPATIBILITY_MATRIX: Record = { */ export const DEFAULT_RHDH_VERSION = '2.0.0'; -export type RhdhVersionSource = 'remote' | 'matrix'; +export type RhdhVersionSource = 'remote' | 'matrix' | 'explicit'; export interface ResolveRhdhVersionOptions { manifestFile?: string; @@ -107,7 +107,7 @@ export function getRhdhGitRef(version: string): string { } /** - * Fetches build-metadata.json from target RHDH repository release branch (falling back to main) + * Fetches build-metadata.json from the target RHDH repository branch. */ export async function fetchRemoteRhdhMetadata( rhdhVersion: string, @@ -119,49 +119,44 @@ export async function fetchRemoteRhdhMetadata( process.env.RHDH_METADATA_BASE_URL || 'https://raw.githubusercontent.com/redhat-developer/rhdh'; - // If targeting a release branch that hasn't been cut yet, candidate fallbacks check main - const candidateRefs = gitRef === 'main' ? ['main'] : [gitRef, 'main']; - - for (const ref of candidateRefs) { - const metadataUrl = `${baseUrl}/${ref}/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) || bsVersion.trim(); - return { - rhdhVersion: resolvedRhdhVersion, - backstageVersion: validBsVersion, - }; - } + 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) || bsVersion.trim(); + return { + rhdhVersion: resolvedRhdhVersion, + backstageVersion: validBsVersion, + }; } - } catch { - // Try next candidate ref or fall through - } finally { - clearTimeout(timeoutId); } + } catch { + // Fall back to the static compatibility matrix. + } finally { + clearTimeout(timeoutId); } return undefined; @@ -249,31 +244,18 @@ async function resolveBackstageVersionForRhdh( } | undefined > { - // Support explicit Backstage versions (e.g. "backstage:1.54.0" or "1.54.0") + // Support explicit Backstage versions (e.g. "backstage:1.54.0"). if (normalized.startsWith('backstage:')) { const bsVer = normalized.replace(/^backstage:/, '').trim(); if (semver.valid(bsVer)) { return { backstageVersion: bsVer, resolvedRhdhVersion: `backstage:${bsVer}`, - source: 'matrix', + source: 'explicit', }; } } - const parsed = semver.coerce(normalized); - if ( - parsed?.major === 1 && - parsed.minor >= 30 && - !RHDH_COMPATIBILITY_MATRIX[normalized] - ) { - return { - backstageVersion: normalized, - resolvedRhdhVersion: `backstage:${normalized}`, - source: 'matrix', - }; - } - if (!isOffline) { const remote = await fetchRemoteRhdhMetadata(normalized); if (remote) { @@ -318,10 +300,7 @@ export async function resolveRhdhVersion( return cached; } - const isOffline = - options?.offline || - process.env.RHDH_OFFLINE === 'true' || - Boolean(options?.manifestFile || process.env.BACKSTAGE_MANIFEST_FILE); + const isOffline = options?.offline || process.env.RHDH_OFFLINE === 'true'; const resolved = await resolveBackstageVersionForRhdh(normalized, isOffline); if (!resolved) { From e9c5e7ee2dc1dd4222683ec4368a80433e17519a Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Wed, 9 Sep 2026 08:51:25 -0400 Subject: [PATCH 09/12] fix: address check-versions review findings Assisted-by: openai/gpt-5.6-terra rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/check-versions/command.test.ts | 18 +++-- src/commands/check-versions/command.ts | 14 ++-- src/commands/index.ts | 8 +- src/lib/backstageVersion.ts | 14 +++- src/lib/rhdhVersion.test.ts | 89 ++++++++++++++++++++- src/lib/rhdhVersion.ts | 38 ++++++--- 6 files changed, 150 insertions(+), 31 deletions(-) diff --git a/src/commands/check-versions/command.test.ts b/src/commands/check-versions/command.test.ts index 8e9e94e..3aac891 100644 --- a/src/commands/check-versions/command.test.ts +++ b/src/commands/check-versions/command.test.ts @@ -17,6 +17,8 @@ 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'; @@ -57,6 +59,7 @@ describe('checkPluginDependencies', () => { async function runCommandWithOutput(opts: any = {}) { let stdout = ''; let stderr = ''; + let error: Error | undefined; const stdoutSpy = jest .spyOn(process.stdout, 'write') .mockImplementation((chunk: any) => { @@ -72,12 +75,14 @@ describe('checkPluginDependencies', () => { try { await command(opts); + } catch (caughtError) { + error = caughtError as Error; } finally { stdoutSpy.mockRestore(); stderrSpy.mockRestore(); } - return { stdout, stderr, exitCode: process.exitCode }; + return { stdout, stderr, error }; } beforeEach(async () => { @@ -124,7 +129,7 @@ describe('checkPluginDependencies', () => { const result = await checkPluginDependencies({ targetDir: tmpDir }); - expect(result.valid).toBe(false); + expect(result.valid).toBe(true); expect(result.counts.matching).toBe(3); expect(result.counts.mismatched).toBe(0); expect(result.counts.unmanifested).toBe(0); @@ -184,7 +189,8 @@ describe('checkPluginDependencies', () => { const res = await runCommandWithOutput({}); expect(res.stderr).toContain('cannot verify backstage:^'); - expect(res.exitCode).toBe(1); + 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 () => { @@ -196,7 +202,7 @@ describe('checkPluginDependencies', () => { const parsed = JSON.parse(res.stdout); expect(parsed.valid).toBe(false); expect(parsed.counts.mismatched).toBe(1); - expect(res.exitCode).toBe(1); + expect(res.error).toEqual(new ExitCodeError(1)); }); it('prints formatted table and remediation when run in human mode', async () => { @@ -209,7 +215,7 @@ describe('checkPluginDependencies', () => { 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.exitCode).toBe(1); + expect(res.error).toEqual(new ExitCodeError(1)); }); it('prints success message when dependencies are aligned', async () => { @@ -219,7 +225,7 @@ describe('checkPluginDependencies', () => { const res = await runCommandWithOutput({}); expect(res.stderr).toContain('All @backstage dependencies are aligned'); - expect(res.exitCode).toBeUndefined(); + expect(res.error).toBeUndefined(); }); }); }); diff --git a/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts index 7414dab..eec74b3 100644 --- a/src/commands/check-versions/command.ts +++ b/src/commands/check-versions/command.ts @@ -19,6 +19,8 @@ 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'; @@ -59,7 +61,6 @@ export interface CheckVersionsResult { export interface CheckVersionsOptions { rhdhVersion?: string; manifestFile?: string; - json?: boolean; targetDir?: string; } @@ -173,7 +174,7 @@ export async function checkPluginDependencies( 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 && unverifiable === 0; + const valid = mismatched === 0 && unmanifested === 0; return { rhdhVersion: resolved.rhdhVersion, @@ -200,13 +201,12 @@ export async function command(opts: OptionValues): Promise { const result = await checkPluginDependencies({ rhdhVersion, manifestFile, - json, }); if (json) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); if (!result.valid) { - process.exitCode = 1; + throw new ExitCodeError(1); } return; } @@ -283,7 +283,11 @@ export async function command(opts: OptionValues): Promise { process.stderr.write( `\n${chalk.yellow('Remediation:')} Run ${upgradeCmd} to align dependencies with RHDH v${result.rhdhVersion}.\n\n`, ); - process.exitCode = 1; + throw new ExitCodeError(1); + } else if (result.counts.unverifiable > 0) { + process.stderr.write( + `\n${chalk.yellow(`⚠ ${result.counts.unverifiable} backstage:^ dependencies cannot be verified against the target RHDH release.`)}\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/index.ts b/src/commands/index.ts index 7ae7e1e..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) { @@ -149,7 +149,6 @@ export function registerPluginCommand(program: Command) { command .command('check-versions') - .alias('versions:lint') .description( 'Check dynamic plugin dependencies in package.json against target RHDH release Backstage manifest', ) @@ -179,9 +178,12 @@ function lazy( const actionFunc = await getActionFunc(); await actionFunc(...args); - process.exit(process.exitCode ?? 0); + 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 57a9e34..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; /** @@ -95,7 +99,11 @@ export async function getBackstageManifest( const versionsBaseUrl = options?.versionsBaseUrl || process.env.BACKSTAGE_VERSIONS_BASE_URL; - if (cachedManifest?.version === backstageVersion && !manifestFile) { + if ( + cachedManifest?.version === backstageVersion && + cachedManifest.versionsBaseUrl === versionsBaseUrl && + !manifestFile + ) { return cachedManifest.packages; } @@ -135,7 +143,7 @@ export 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 index 55cd1df..2dfa152 100644 --- a/src/lib/rhdhVersion.test.ts +++ b/src/lib/rhdhVersion.test.ts @@ -14,10 +14,14 @@ * limitations under the License. */ -import { clearManifestCache } from './backstageVersion'; import fs from 'fs-extra'; -import os from 'os'; +import os from 'node:os'; import path from 'node:path'; + +import { + clearManifestCache, + getCurrentBackstageVersion, +} from './backstageVersion'; import { clearRhdhVersionCache, DEFAULT_RHDH_VERSION, @@ -29,8 +33,17 @@ import { 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, @@ -80,6 +93,7 @@ describe('rhdhVersion', () => { delete process.env.RHDH_OFFLINE; delete process.env.BACKSTAGE_MANIFEST_FILE; delete process.env.BACKSTAGE_VERSIONS_BASE_URL; + mockGetCurrentBackstageVersion.mockReset(); }); afterEach(() => { @@ -125,6 +139,10 @@ describe('rhdhVersion', () => { 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', () => { @@ -197,6 +215,14 @@ describe('rhdhVersion', () => { 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', () => { @@ -235,6 +261,25 @@ describe('rhdhVersion', () => { ); }); + 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({}); @@ -300,6 +345,26 @@ describe('rhdhVersion', () => { 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({}); @@ -324,5 +389,25 @@ describe('rhdhVersion', () => { 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 index bdd1f8f..e033efb 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -15,6 +15,7 @@ */ import semver from 'semver'; + import { getBackstageManifest, getCurrentBackstageVersion, @@ -92,11 +93,15 @@ export function normalizeRhdhVersion(input?: string): string { /** * Maps an RHDH version or branch name to a GitHub repository ref/branch in redhat-developer/rhdh */ -export function getRhdhGitRef(version: string): string { +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) { @@ -114,6 +119,9 @@ export async function fetchRemoteRhdhMetadata( 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 || @@ -146,11 +154,13 @@ export async function fetchRemoteRhdhMetadata( rhdhVersion; if (bsVersion && typeof bsVersion === 'string') { - const validBsVersion = semver.clean(bsVersion) || bsVersion.trim(); - return { - rhdhVersion: resolvedRhdhVersion, - backstageVersion: validBsVersion, - }; + const validBsVersion = semver.clean(bsVersion); + if (validBsVersion) { + return { + rhdhVersion: resolvedRhdhVersion, + backstageVersion: validBsVersion, + }; + } } } } catch { @@ -247,10 +257,11 @@ async function resolveBackstageVersionForRhdh( // Support explicit Backstage versions (e.g. "backstage:1.54.0"). if (normalized.startsWith('backstage:')) { const bsVer = normalized.replace(/^backstage:/, '').trim(); - if (semver.valid(bsVer)) { + const parsed = semver.coerce(bsVer); + if (parsed) { return { - backstageVersion: bsVer, - resolvedRhdhVersion: `backstage:${bsVer}`, + backstageVersion: parsed.version, + resolvedRhdhVersion: `backstage:${parsed.version}`, source: 'explicit', }; } @@ -293,15 +304,18 @@ export async function resolveRhdhVersion( ): Promise { const targetVersion = rhdhVersionInput || (await getDefaultTargetVersion()); const normalized = normalizeRhdhVersion(targetVersion); - const cacheKey = `${normalized}:${options?.manifestFile || ''}:${options?.offline || ''}`; + 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 isOffline = options?.offline || process.env.RHDH_OFFLINE === 'true'; - const resolved = await resolveBackstageVersionForRhdh(normalized, isOffline); if (!resolved) { const supported = getSupportedRhdhVersions().join(', '); From 3b1a42e8f3806aba5d32296306b9e4f2c8950a03 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Wed, 9 Sep 2026 09:14:58 -0400 Subject: [PATCH 10/12] docs: document compatibility matrix maintenance Assisted-by: openai/gpt-5.6-terra rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f1efccb..6f9c49f 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Use `--json` for machine-readable output. To target a Backstage version directly 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 From bd6834571313234c5fd5e57206ab0eb301ffa6a8 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Wed, 9 Sep 2026 09:19:03 -0400 Subject: [PATCH 11/12] refactor: simplify unverifiable dependency output Assisted-by: openai/gpt-5.6-terra rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/commands/check-versions/command.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/check-versions/command.ts b/src/commands/check-versions/command.ts index eec74b3..735993a 100644 --- a/src/commands/check-versions/command.ts +++ b/src/commands/check-versions/command.ts @@ -285,9 +285,10 @@ export async function command(opts: OptionValues): Promise { ); throw new ExitCodeError(1); } else if (result.counts.unverifiable > 0) { - process.stderr.write( - `\n${chalk.yellow(`⚠ ${result.counts.unverifiable} backstage:^ dependencies cannot be verified against the target RHDH release.`)}\n\n`, + 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`, From 687923a5680c47680f3eff3ff15deddf101b0ae4 Mon Sep 17 00:00:00 2001 From: Stan Lewis Date: Wed, 9 Sep 2026 11:46:57 -0400 Subject: [PATCH 12/12] fix: default to RHDH 2.1.0 Assisted-by: openai/gpt-5.6-terra rh-pre-commit.version: 2.4.0 rh-pre-commit.check-secrets: ENABLED --- src/lib/rhdhVersion.test.ts | 1 + src/lib/rhdhVersion.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/rhdhVersion.test.ts b/src/lib/rhdhVersion.test.ts index 2dfa152..de56319 100644 --- a/src/lib/rhdhVersion.test.ts +++ b/src/lib/rhdhVersion.test.ts @@ -102,6 +102,7 @@ describe('rhdhVersion', () => { 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); }); diff --git a/src/lib/rhdhVersion.ts b/src/lib/rhdhVersion.ts index e033efb..35aaa10 100644 --- a/src/lib/rhdhVersion.ts +++ b/src/lib/rhdhVersion.ts @@ -41,7 +41,7 @@ export const RHDH_COMPATIBILITY_MATRIX: Record = { /** * Default stable RHDH GA release version */ -export const DEFAULT_RHDH_VERSION = '2.0.0'; +export const DEFAULT_RHDH_VERSION = '2.1.0'; export type RhdhVersionSource = 'remote' | 'matrix' | 'explicit';