diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index dd05a217e9c..5635c8ae8c4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -79,6 +79,9 @@ jobs: - name: Checking for mis-matching dependencies... run: node common/scripts/install-run-rush.js check + - name: Checking for mis-matching transitive dependencies... + run: node common/scripts/check-versions.js + - name: Installing... run: node common/scripts/install-run-rush.js install @@ -223,6 +226,10 @@ jobs: - name: Checking for mis-matching dependencies... run: node common/scripts/install-run-rush.js check + - name: Checking for mis-matching transitive dependencies... + run: node common/scripts/check-versions.js + + - name: Installing... run: node common/scripts/install-run-rush.js install @@ -349,6 +356,9 @@ jobs: - name: Checking for mis-matching dependencies... run: node common/scripts/install-run-rush.js check + - name: Checking for mis-matching transitive dependencies... + run: node common/scripts/check-versions.js + - name: Installing... run: node common/scripts/install-run-rush.js install @@ -440,6 +450,9 @@ jobs: - name: Checking for mis-matching dependencies... run: node common/scripts/install-run-rush.js check + - name: Checking for mis-matching transitive dependencies... + run: node common/scripts/check-versions.js + - name: Installing... run: node common/scripts/install-run-rush.js install @@ -514,6 +527,9 @@ jobs: - name: Checking for mis-matching dependencies... run: node common/scripts/install-run-rush.js check + - name: Checking for mis-matching transitive dependencies... + run: node common/scripts/check-versions.js + - name: Installing... run: node common/scripts/install-run-rush.js install diff --git a/common/scripts/check-versions.js b/common/scripts/check-versions.js new file mode 100755 index 00000000000..93edba11c8e --- /dev/null +++ b/common/scripts/check-versions.js @@ -0,0 +1,464 @@ +#!/usr/bin/env node + +/** + * This script validates that all @hcengineering packages use the same versions + * across all dependencies (including devDependencies). + * + * This is similar to `rush check` but also checks for transitive dependencies + * and only validates @hcengineering packages. + * + * The script will fail with exit code 1 if any version mismatches are found. + */ + +const fs = require('fs') +const path = require('path') +const execSync = require('child_process').execSync + +const SCOPE = '@hcengineering' + +/** + * Find the repository root by looking for rush.json + * @returns {string} Path to repository root + */ +function findRepoRoot() { + let currentDir = __dirname + + while (currentDir !== '/') { + const rushJsonPath = path.join(currentDir, 'rush.json') + if (fs.existsSync(rushJsonPath)) { + return currentDir + } + currentDir = path.dirname(currentDir) + } + + throw new Error('Could not find repository root (rush.json not found)') +} + +/** + * Parse pnpm-lock.yaml to extract resolved @hcengineering package versions + * and track which packages depend on which versions + * @returns {Object} Map of package names to version -> dependents mapping + */ +function parseLockfile() { + const repoRoot = findRepoRoot() + const lockfilePath = path.join(repoRoot, 'common/config/rush/pnpm-lock.yaml') + + if (!fs.existsSync(lockfilePath)) { + console.warn('⚠️ pnpm-lock.yaml not found, skipping lockfile validation') + return {} + } + + const lockfileContent = fs.readFileSync(lockfilePath, 'utf-8') + const lines = lockfileContent.split('\n') + const lockfileVersions = {} + + // Track current package in the packages section + let currentPackage = null + let currentPackageName = null + let currentPackageVersion = null + let inPackagesSection = false + let inDependenciesSection = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Check if we're in the packages section + if (line.match(/^packages:/)) { + inPackagesSection = true + continue + } + + // Parse @hcengineering package entries like ' @hcengineering/platform@0.7.3:' or '@hcengineering/analytics@0.7.4:' + if (inPackagesSection && line.match(new RegExp(`^ '?(${SCOPE}/[^@']+)@([^':()]+)(?:\\([^)]*\\))*'?:`))) { + const packageMatch = line.match(new RegExp(`^ '?(${SCOPE}/[^@']+)@([^':()]+)(?:\\([^)]*\\))*'?:`)) + if (packageMatch) { + currentPackageName = packageMatch[1] + currentPackageVersion = packageMatch[2] + currentPackage = `${currentPackageName}@${currentPackageVersion}` + inDependenciesSection = false + + // Track this version exists + if (!lockfileVersions[currentPackageName]) { + lockfileVersions[currentPackageName] = {} + } + if (!lockfileVersions[currentPackageName][currentPackageVersion]) { + lockfileVersions[currentPackageName][currentPackageVersion] = new Set() + } + } + continue + } + + // Check if we're entering dependencies section + if (currentPackage && line.trim() === 'dependencies:') { + inDependenciesSection = true + continue + } + + // Exit dependencies section + if (inDependenciesSection && line.match(/^ [a-zA-Z]/)) { + const nextSectionMatch = line.match(/^ ([a-zA-Z]+):/) + if (nextSectionMatch && nextSectionMatch[1] !== 'dependencies' && nextSectionMatch[1] !== 'devDependencies') { + inDependenciesSection = false + } + } + + // Exit current package when we hit another package definition at the same level + if (currentPackage && line.match(/^ ['"]?[@a-zA-Z]/)) { + currentPackage = null + currentPackageName = null + currentPackageVersion = null + inDependenciesSection = false + } + + // Parse dependency lines like " '@hcengineering/platform': 0.7.3" + // This tells us that currentPackageName@currentPackageVersion depends on @hcengineering/platform@0.7.3 + if (inDependenciesSection && currentPackageName && currentPackageVersion) { + const depMatch = line.match(/^\s{6}'?(@hcengineering\/[^']+)'?:\s+([0-9.]+)/) + if (depMatch) { + const depPackageName = depMatch[1] + const depVersion = depMatch[2] + + // Record that depPackageName@depVersion is required by currentPackageName@currentPackageVersion + if (!lockfileVersions[depPackageName]) { + lockfileVersions[depPackageName] = {} + } + if (!lockfileVersions[depPackageName][depVersion]) { + lockfileVersions[depPackageName][depVersion] = new Set() + } + lockfileVersions[depPackageName][depVersion].add(currentPackage) + } + } + } + + return lockfileVersions +} + +/** + * Get all projects from rush + * @returns {Array} List of projects with name, version, and path + */ +function getProjects() { + console.log('📦 Loading Rush projects...') + try { + const repoRoot = findRepoRoot() + const output = execSync('node common/scripts/install-run-rush.js list -p --json', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: repoRoot + }) + + // Parse the JSON output (skip any warnings/logs before the JSON) + const lines = output.split('\n') + let jsonStart = -1 + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().startsWith('{')) { + jsonStart = i + break + } + } + + if (jsonStart === -1) { + throw new Error('Could not find JSON output from rush list') + } + + const jsonOutput = lines.slice(jsonStart).join('\n') + const config = JSON.parse(jsonOutput) + return config.projects + } catch (error) { + console.error('❌ Error loading Rush projects:', error.message) + process.exit(1) + } +} + +/** + * Read package.json for a project + * @param {string} projectPath - Path to the project + * @returns {Object} Parsed package.json + */ +function readPackageJson(projectPath) { + const repoRoot = findRepoRoot() + const fullPath = path.join(repoRoot, projectPath) + const packageJsonPath = path.join(fullPath, 'package.json') + try { + const content = fs.readFileSync(packageJsonPath, 'utf-8') + return JSON.parse(content) + } catch (error) { + console.warn(`⚠️ Could not read ${packageJsonPath}: ${error.message}`) + return null + } +} + +/** + * Extract @hcengineering dependencies from package.json + * @param {Object} packageJson - Parsed package.json + * @param {string} packageName - Name of the package + * @returns {Object} Map of dependency name to version + */ +function extractHceDependencies(packageJson, packageName) { + const dependencies = {} + + // Check both dependencies and devDependencies + const depTypes = ['dependencies', 'devDependencies'] + + for (const depType of depTypes) { + const deps = packageJson[depType] + if (!deps) continue + + for (const [depName, version] of Object.entries(deps)) { + if (depName.startsWith(SCOPE)) { + dependencies[depName] = { + version: version, + type: depType, + usedBy: packageName + } + } + } + } + + return dependencies +} + +/** + * Build a map of all dependency versions across all packages + * @param {Array} projects - List of projects + * @returns {Object} Map of dependency name to list of versions and users + */ +function buildDependencyMap(projects) { + console.log('🔍 Scanning all packages for @hcengineering dependencies...\n') + + const dependencyMap = {} + let totalPackages = 0 + let totalDependencies = 0 + + for (const project of projects) { + const packageJson = readPackageJson(project.path) + if (!packageJson) continue + + totalPackages++ + const dependencies = extractHceDependencies(packageJson, project.name) + + for (const [depName, info] of Object.entries(dependencies)) { + if (!dependencyMap[depName]) { + dependencyMap[depName] = {} + } + + if (!dependencyMap[depName][info.version]) { + dependencyMap[depName][info.version] = [] + } + + dependencyMap[depName][info.version].push({ + package: project.name, + type: info.type + }) + + totalDependencies++ + } + } + + console.log(`✅ Scanned ${totalPackages} packages`) + console.log(`✅ Found ${totalDependencies} @hcengineering dependencies\n`) + + return dependencyMap +} + +/** + * Find version mismatches in the dependency map + * @param {Object} dependencyMap - Map of dependencies to versions + * @returns {Object} Object with mismatches array and error flag + */ +function findMismatches(dependencyMap) { + const mismatches = [] + + for (const [depName, versions] of Object.entries(dependencyMap)) { + const versionList = Object.keys(versions) + + if (versionList.length > 1) { + mismatches.push({ + dependency: depName, + versions: versions + }) + } + } + + return mismatches +} + +/** + * Format and display mismatches + * @param {Array} mismatches - List of mismatched dependencies + */ +function displayMismatches(mismatches) { + console.log('━'.repeat(80)) + console.log('❌ VERSION MISMATCHES FOUND') + console.log('━'.repeat(80)) + console.log() + + for (const mismatch of mismatches) { + console.log(`📦 ${mismatch.dependency}`) + console.log() + + const versionList = Object.keys(mismatch.versions).sort() + + for (const version of versionList) { + const users = mismatch.versions[version] + console.log(` Version: ${version}`) + console.log(` Used by ${users.length} package(s):`) + + // Sort users for consistent output + users.sort((a, b) => a.package.localeCompare(b.package)) + + // Show first 10 users, then summarize if more + const displayUsers = users.slice(0, 10) + for (const user of displayUsers) { + console.log(` - ${user.package} (${user.type})`) + } + + if (users.length > 10) { + console.log(` ... and ${users.length - 10} more`) + } + + console.log() + } + + console.log('─'.repeat(80)) + console.log() + } +} + +/** + * Find mismatches in the lockfile + * @param {Object} lockfileVersions - Map of package names to version -> dependents mapping + * @returns {Array} Array of packages with multiple versions in lockfile + */ +function findLockfileMismatches(lockfileVersions) { + const mismatches = [] + + for (const [packageName, versionMap] of Object.entries(lockfileVersions)) { + const versions = Object.keys(versionMap) + if (versions.length > 1) { + mismatches.push({ + package: packageName, + versionMap: versionMap + }) + } + } + + return mismatches +} + +/** + * Display lockfile mismatches + * @param {Array} mismatches - Array of lockfile mismatches + */ +function displayLockfileMismatches(mismatches) { + console.log('━'.repeat(80)) + console.log('❌ LOCKFILE VERSION MISMATCHES FOUND') + console.log('━'.repeat(80)) + console.log() + console.log('The following @hcengineering packages have multiple resolved versions') + console.log('in pnpm-lock.yaml (transitive dependencies):') + console.log() + + for (const mismatch of mismatches) { + console.log(`📦 ${mismatch.package}`) + console.log() + + const versions = Object.keys(mismatch.versionMap).sort() + + for (const version of versions) { + const dependents = Array.from(mismatch.versionMap[version]) + console.log(` Version ${version}:`) + console.log(` Required by ${dependents.length} package(s):`) + + // Sort dependents for consistent output + dependents.sort() + + // Show first 10 dependents, then summarize if more + const displayDependents = dependents.slice(0, 10) + for (const dependent of displayDependents) { + // Clean up the path for better readability + const cleanPath = dependent.replace(/^\.\.\/\.\.\//, '') + console.log(` - ${cleanPath}`) + } + + if (dependents.length > 10) { + console.log(` ... and ${dependents.length - 10} more`) + } + + console.log() + } + + console.log('─'.repeat(80)) + console.log() + } +} + +/** + * Main function + */ +function main() { + console.log('🚀 Checking @hcengineering dependency versions...\n') + + // Get all projects from rush + const projects = getProjects() + + // Build dependency map from package.json files + const dependencyMap = buildDependencyMap(projects) + + // Find mismatches in package.json files + const packageJsonMismatches = findMismatches(dependencyMap) + + // Parse lockfile and find mismatches there + console.log('🔒 Checking pnpm-lock.yaml for resolved version mismatches...\n') + const lockfileVersions = parseLockfile() + const lockfileMismatches = findLockfileMismatches(lockfileVersions) + + console.log(`✅ Found ${Object.keys(lockfileVersions).length} unique @hcengineering packages in lockfile`) + console.log(`${lockfileMismatches.length > 0 ? '❌' : '✅'} Found ${lockfileMismatches.length} packages with multiple resolved versions\n`) + + const hasErrors = packageJsonMismatches.length > 0 || lockfileMismatches.length > 0 + + if (!hasErrors) { + console.log('━'.repeat(80)) + console.log('✅ SUCCESS - All @hcengineering dependencies use consistent versions!') + console.log('━'.repeat(80)) + console.log() + console.log('✓ All package.json dependencies are consistent') + console.log('✓ All resolved versions in pnpm-lock.yaml are consistent') + console.log() + process.exit(0) + } else { + if (packageJsonMismatches.length > 0) { + displayMismatches(packageJsonMismatches) + console.log(`❌ PACKAGE.JSON ISSUES: Found ${packageJsonMismatches.length} dependencies with mismatched versions`) + console.log() + } + + if (lockfileMismatches.length > 0) { + displayLockfileMismatches(lockfileMismatches) + console.log(`❌ LOCKFILE ISSUES: Found ${lockfileMismatches.length} packages with multiple resolved versions`) + console.log() + } + + console.log('To fix these issues:') + console.log(' 1. Update package.json files to use consistent versions') + console.log(' 2. Run: rush update') + console.log(' 3. Run: rush rebuild') + console.log(' 4. Run this script again to verify') + console.log() + process.exit(1) + } +} + +// Run the script +if (require.main === module) { + main() +} + +module.exports = { + getProjects, + buildDependencyMap, + findMismatches, + parseLockfile, + findLockfileMismatches +}