UNPKG

nx

Version:

The core Nx plugin contains the core functionality of Nx like the project graph, nx commands and task orchestration.

660 lines (658 loc) • 32.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ReleaseGraph = exports.validReleaseVersionPrefixes = void 0; exports.createReleaseGraph = createReleaseGraph; const config_1 = require("../config/config"); const project_logger_1 = require("../version/project-logger"); const resolve_current_version_1 = require("../version/resolve-current-version"); const topological_sort_1 = require("../version/topological-sort"); const version_actions_1 = require("../version/version-actions"); const git_1 = require("./git"); const shared_1 = require("./shared"); exports.validReleaseVersionPrefixes = ['auto', '', '~', '^', '=']; /** * The complete release graph containing all relationships, caches, and computed data * necessary for efficient release operations across versioning, changelog, and publishing. * * This class encapsulates the complex dependency graph between projects and release groups, * providing convenient methods for querying relationships and accessing cached data. */ class ReleaseGraph { constructor(releaseGroups, filters) { this.releaseGroups = releaseGroups; this.filters = filters; this.projectToReleaseGroup = new Map(); this.projectToDependents = new Map(); this.projectToDependencies = new Map(); this.projectToUpdateDependentsSetting = new Map(); this.groupGraph = new Map(); this.sortedReleaseGroups = []; this.sortedProjects = new Map(); this.allProjectsConfiguredForNxRelease = new Set(); this.allProjectsToProcess = new Set(); this.finalConfigsByProject = new Map(); this.projectsToVersionActions = new Map(); this.uniqueAfterAllProjectsVersioned = new Map(); this.projectLoggers = new Map(); this.cachedCurrentVersions = new Map(); this.cachedLatestMatchingGitTag = new Map(); this.currentVersionsPerFixedReleaseGroup = new Map(); this.originalDependentProjectsPerProject = new Map(); this.releaseGroupToFilteredProjects = new Map(); this.originalFilteredProjects = new Set(); /** * User-friendly log describing what the filter matched. * Null if no filters were applied. */ this.filterLog = null; } /** * Initialize the graph by building all relationships and caches * @internal - Called by createReleaseGraph(), not meant for external use */ async init(options) { // Step 1: Setup project to release group mapping this.setupProjectReleaseGroupMapping(); // Step 2: Apply initial filtering to determine base set of projects and groups to process this.applyInitialFiltering(); // Step 3: Setup projects to process and resolve version actions await this.setupProjectsToProcess(options); // Step 4: Precompute dependency relationships await this.precomputeDependencyRelationships(options.tree, options.projectGraph); // Step 5: Apply dependency-aware filtering based on updateDependents this.applyDependencyAwareFiltering(); // Step 5: Build the group graph structure this.buildGroupGraphStructure(); // Step 6: Resolve current versions for all projects to process (unless explicitly skipped) if (!options.skipVersionResolution) { await this.resolveCurrentVersionsForProjects(options.tree, options.projectGraph, options.preid ?? ''); } // Step 7: Build dependency relationships between groups this.buildGroupDependencyGraph(); // Step 8: Topologically sort groups and projects this.sortedReleaseGroups = this.topologicallySortReleaseGroups(); for (const group of this.releaseGroups) { this.sortedProjects.set(group.name, this.topologicallySortProjects(group)); } // Step 9: Populate dependent projects data await this.populateDependentProjectsData(options.tree, options.projectGraph); } /** * Setup mapping from project to release group and cache updateDependents settings */ setupProjectReleaseGroupMapping() { for (const group of this.releaseGroups) { for (const project of group.projects) { this.projectToReleaseGroup.set(project, group); const updateDependents = group.version?.updateDependents || 'always'; this.projectToUpdateDependentsSetting.set(project, updateDependents); } } } /** * Apply initial filtering to construct releaseGroupToFilteredProjects based on filters. * This determines the base set of projects and groups before considering dependencies. */ applyInitialFiltering() { const matchedReleaseGroups = []; for (const releaseGroup of this.releaseGroups) { // If group filter is applied and this group doesn't match, skip it entirely if (this.filters.groups?.length && !this.filters.groups.includes(releaseGroup.name)) { continue; } // If filtering by groups (not projects), include ALL projects in the matched group if (this.filters.groups?.length && !this.filters.projects?.length) { this.releaseGroupToFilteredProjects.set(releaseGroup, new Set(releaseGroup.projects)); matchedReleaseGroups.push(releaseGroup); continue; } // If filtering by projects, filter down to matching projects const filteredProjects = new Set(); for (const project of releaseGroup.projects) { if (this.filters.projects?.length && !this.filters.projects.includes(project)) { continue; } filteredProjects.add(project); } // If no filters applied or group has matching projects, include it if (filteredProjects.size > 0 || !this.hasAnyFilters()) { const projectsToInclude = filteredProjects.size > 0 ? filteredProjects : new Set(releaseGroup.projects); this.releaseGroupToFilteredProjects.set(releaseGroup, projectsToInclude); matchedReleaseGroups.push(releaseGroup); } } // Update this.releaseGroups to only include matched groups if (this.hasAnyFilters()) { this.releaseGroups = matchedReleaseGroups; } } /** * Check if any filters are applied */ hasAnyFilters() { return !!(this.filters.projects?.length || this.filters.groups?.length); } /** * Setup projects to process and resolve version actions */ async setupProjectsToProcess(options) { const { tree, projectGraph, nxReleaseConfig, filters, firstRelease, versionActionsOptionsOverrides, } = options; let projectsToProcess = new Set(); const resolveVersionActionsForProjectCallbacks = []; // Precompute all projects in nx release config for (const [groupName, group] of Object.entries(nxReleaseConfig.groups)) { for (const project of group.projects) { this.allProjectsConfiguredForNxRelease.add(project); this.projectLoggers.set(project, new project_logger_1.ProjectLogger(project)); if (filters.groups?.includes(groupName)) { projectsToProcess.add(project); } else if (filters.projects?.includes(project)) { projectsToProcess.add(project); } const projectGraphNode = projectGraph.nodes[project]; const releaseGroup = this.projectToReleaseGroup.get(project); const finalConfigForProject = ReleaseGraph.resolveFinalConfigForProject(releaseGroup, projectGraphNode, firstRelease, versionActionsOptionsOverrides); this.finalConfigsByProject.set(project, finalConfigForProject); resolveVersionActionsForProjectCallbacks.push(async () => { const { versionActionsPath, versionActions, afterAllProjectsVersioned, } = await (0, version_actions_1.resolveVersionActionsForProject)(tree, releaseGroup, projectGraphNode, finalConfigForProject); if (!this.uniqueAfterAllProjectsVersioned.has(versionActionsPath)) { this.uniqueAfterAllProjectsVersioned.set(versionActionsPath, afterAllProjectsVersioned); } let versionActionsToUse = versionActions; const shouldSkip = (0, shared_1.shouldSkipVersionActions)(finalConfigForProject.dockerOptions, project); if (shouldSkip) { versionActionsToUse = new version_actions_1.NOOP_VERSION_ACTIONS(releaseGroup, projectGraphNode, finalConfigForProject); } this.projectsToVersionActions.set(project, versionActionsToUse); }); } } if (!filters.groups?.length && !filters.projects?.length) { projectsToProcess = this.allProjectsConfiguredForNxRelease; } if (projectsToProcess.size === 0) { throw new Error('No projects are set to be processed, please report this as a bug on https://github.com/nrwl/nx/issues'); } this.allProjectsToProcess = new Set(projectsToProcess); for (const cb of resolveVersionActionsForProjectCallbacks) { await cb(); } } /** * Precompute dependency relationships between all projects */ async precomputeDependencyRelationships(tree, projectGraph) { for (const projectName of this.allProjectsConfiguredForNxRelease) { const versionActions = this.projectsToVersionActions.get(projectName); if (!this.projectToDependencies.has(projectName)) { this.projectToDependencies.set(projectName, new Set()); } const deps = await versionActions.readDependencies(tree, projectGraph); for (const dep of deps) { if (!this.allProjectsConfiguredForNxRelease.has(dep.target)) { continue; } this.projectToDependencies.get(projectName).add(dep.target); if (!this.projectToDependents.has(dep.target)) { this.projectToDependents.set(dep.target, new Set()); } this.projectToDependents.get(dep.target).add(projectName); } } } /** * Apply dependency-aware filtering that considers updateDependents configuration. * This includes transitive dependents based on updateDependents setting ('always' by default, or 'auto'). */ applyDependencyAwareFiltering() { // Track the original filtered projects before adding dependents this.originalFilteredProjects = new Set(this.allProjectsToProcess); if (!this.hasAnyFilters()) { // No filtering applied, nothing to do return; } // Validate filtering against fixed release groups this.validateFilterAgainstFixedGroups(); // Find all dependents that need to be included based on updateDependents setting this.findDependentsToProcess(); // Generate user-friendly filter log this.generateFilterLog(); } /** * Validate that the filter doesn't try to isolate projects in fixed release groups */ validateFilterAgainstFixedGroups() { if (!this.filters.projects?.length) { // Group filtering doesn't have this issue return; } for (const releaseGroup of this.releaseGroups) { if (releaseGroup.projectsRelationship !== 'fixed') { continue; } const filteredProjectsInGroup = releaseGroup.projects.filter((p) => this.releaseGroupToFilteredProjects.get(releaseGroup)?.has(p)); if (filteredProjectsInGroup.length > 0 && filteredProjectsInGroup.length < releaseGroup.projects.length) { throw new Error(`Cannot filter to a subset of projects within fixed release group "${releaseGroup.name}". ` + `Filtered projects: [${filteredProjectsInGroup.join(', ')}], ` + `All projects in group: [${releaseGroup.projects.join(', ')}]. ` + `Either filter to all projects in the group, use --groups to filter by group, or change the group to "independent".`); } } } /** * Find dependents that should be included in processing based on updateDependents configuration */ findDependentsToProcess() { const projectsToProcess = Array.from(this.allProjectsToProcess); const allTrackedDependents = new Set(); const dependentsToProcess = new Set(); const additionalGroups = new Map(); // BFS traversal to find all transitive dependents let currentLevel = [...projectsToProcess]; while (currentLevel.length > 0) { const nextLevel = []; const dependents = this.getAllNonImplicitDependents(currentLevel); for (const dep of dependents) { if (allTrackedDependents.has(dep) || this.allProjectsToProcess.has(dep)) { continue; } allTrackedDependents.add(dep); // Check if this dependent should be included based on updateDependents settings const depUpdateDependentsSetting = this.projectToUpdateDependentsSetting.get(dep); // Only include if dependent has 'always' or 'auto' (not 'never') if (depUpdateDependentsSetting !== 'never') { // Find which project(s) in currentLevel this dependent depends on const shouldIncludeDependent = currentLevel.some((proj) => { const projUpdateSetting = this.projectToUpdateDependentsSetting.get(proj); const projDependents = this.getProjectDependents(proj); if (!projDependents.has(dep)) { return false; } // Always include if updateDependents is 'always' if (projUpdateSetting === 'always') { return true; } // For 'auto', include if in the same release group to match historical behavior if (projUpdateSetting === 'auto') { const projGroup = this.getReleaseGroupForProject(proj); const depGroup = this.getReleaseGroupForProject(dep); return projGroup && depGroup && projGroup.name === depGroup.name; } return false; }); if (shouldIncludeDependent) { dependentsToProcess.add(dep); // Track the release group of this dependent const depGroup = this.getReleaseGroupForProject(dep); if (depGroup) { // Check if this group is already in our list by name const groupAlreadyExists = this.releaseGroups.some((g) => g.name === depGroup.name); if (!groupAlreadyExists) { additionalGroups.set(depGroup.name, depGroup); } } } } nextLevel.push(dep); } currentLevel = nextLevel; } dependentsToProcess.forEach((dep) => this.allProjectsToProcess.add(dep)); // Add any additional groups and their filtered projects additionalGroups.forEach((group) => { // When adding groups due to dependents, clear version plans to avoid duplication // Version plans should only be processed for groups that were explicitly included const groupForDependents = { ...group, versionPlans: false, resolvedVersionPlans: false, }; this.releaseGroups.push(groupForDependents); // Add the projects from this group that are actually being processed const projectsInGroup = new Set(group.projects.filter((p) => dependentsToProcess.has(p))); this.releaseGroupToFilteredProjects.set(groupForDependents, projectsInGroup); }); } /** * Generate user-friendly log describing what the filter matched */ generateFilterLog() { if (this.filters.projects?.length) { // Projects filter - only show the originally filtered projects to match old behavior const matchedProjects = Array.from(this.originalFilteredProjects); this.filterLog = { title: `Your filter "${this.filters.projects.join(',')}" matched the following projects:`, bodyLines: matchedProjects.map((p) => { const releaseGroupForProject = this.projectToReleaseGroup.get(p); if (!releaseGroupForProject || releaseGroupForProject.name === config_1.IMPLICIT_DEFAULT_RELEASE_GROUP) { return `- ${p}`; } return `- ${p} (release group "${releaseGroupForProject.name}")`; }), }; } // TODO: add groups filter log } /** * Build the group graph structure */ buildGroupGraphStructure() { for (const group of this.releaseGroups) { // Don't overwrite if already exists (may have been added during filtering) if (!this.groupGraph.has(group.name)) { this.groupGraph.set(group.name, { group, dependencies: new Set(), dependents: new Set(), }); } } } /** * Resolve current versions for all projects that will be processed */ async resolveCurrentVersionsForProjects(tree, projectGraph, preid) { for (const [, releaseGroupNode] of this.groupGraph) { for (const projectName of releaseGroupNode.group.projects) { const projectGraphNode = projectGraph.nodes[projectName]; if (!this.allProjectsToProcess.has(projectName)) { continue; } const versionActions = this.projectsToVersionActions.get(projectName); const finalConfigForProject = this.finalConfigsByProject.get(projectName); let latestMatchingGitTag; const releaseTagPattern = releaseGroupNode.group.releaseTag.pattern; if (finalConfigForProject.currentVersionResolver === 'git-tag') { latestMatchingGitTag = await (0, git_1.getLatestGitTagForPattern)(releaseTagPattern, { projectName: (0, git_1.sanitizeProjectNameForGitTag)(projectGraphNode.name), releaseGroupName: releaseGroupNode.group.name, }, { checkAllBranchesWhen: releaseGroupNode.group.releaseTag.checkAllBranchesWhen, preid: preid, requireSemver: releaseGroupNode.group.releaseTag.requireSemver, strictPreid: releaseGroupNode.group.releaseTag.strictPreid, }); this.cachedLatestMatchingGitTag.set(projectName, latestMatchingGitTag); } const currentVersion = await (0, resolve_current_version_1.resolveCurrentVersion)(tree, projectGraphNode, releaseGroupNode.group, versionActions, this.projectLoggers.get(projectName), this.currentVersionsPerFixedReleaseGroup, finalConfigForProject, releaseTagPattern, latestMatchingGitTag); this.cachedCurrentVersions.set(projectName, currentVersion); } } } /** * Build dependency relationships between release groups */ buildGroupDependencyGraph() { for (const [releaseGroupName, releaseGroupNode] of this.groupGraph) { for (const projectName of releaseGroupNode.group.projects) { const projectDeps = this.getProjectDependencies(projectName); for (const dep of projectDeps) { const dependencyGroup = this.getReleaseGroupNameForProject(dep); if (dependencyGroup && dependencyGroup !== releaseGroupName) { releaseGroupNode.dependencies.add(dependencyGroup); // Only add to dependents if the dependency group exists in the graph // (it may have been filtered out due to user filters) const dependencyGroupNode = this.groupGraph.get(dependencyGroup); if (dependencyGroupNode) { dependencyGroupNode.dependents.add(releaseGroupName); } } } } } } /** * Topologically sort release groups */ topologicallySortReleaseGroups() { const groupNames = Array.from(this.groupGraph.keys()); const getGroupDependencies = (groupName) => { const groupNode = this.groupGraph.get(groupName); if (!groupNode) { return []; } return Array.from(groupNode.dependencies); }; return (0, topological_sort_1.topologicalSort)(groupNames, getGroupDependencies); } /** * Topologically sort projects within a release group */ topologicallySortProjects(releaseGroup) { const projects = releaseGroup.projects.filter((p) => this.allProjectsToProcess.has(p)); const getProjectDependenciesInSameGroup = (project) => { const deps = this.getProjectDependencies(project); return Array.from(deps).filter((dep) => this.getReleaseGroupNameForProject(dep) === releaseGroup.name && this.allProjectsToProcess.has(dep)); }; return (0, topological_sort_1.topologicalSort)(projects, getProjectDependenciesInSameGroup); } async populateDependentProjectsData(tree, projectGraph) { // Populate detailed dependent projects data for all projects being processed for (const projectName of this.allProjectsToProcess) { const dependentProjectNames = Array.from(this.getProjectDependents(projectName)).filter((dep) => this.allProjectsConfiguredForNxRelease.has(dep)); const dependentProjectsData = []; for (const dependentProjectName of dependentProjectNames) { const versionActions = this.projectsToVersionActions.get(dependentProjectName); const { currentVersion, dependencyCollection } = await versionActions.readCurrentVersionOfDependency(tree, projectGraph, projectName); dependentProjectsData.push({ source: dependentProjectName, target: projectName, type: 'static', dependencyCollection, rawVersionSpec: currentVersion, }); } this.originalDependentProjectsPerProject.set(projectName, dependentProjectsData); } } /** * Get all non-implicit dependents for a set of projects */ getAllNonImplicitDependents(projects) { return projects .flatMap((project) => Array.from(this.getProjectDependents(project))) .filter((dep) => !this.allProjectsToProcess.has(dep)); } /** * Resolve final configuration for a project * * NOTE: We are providing ultimate fallback values via ?? here mainly just to keep TypeScript happy. * All default values should have been applied by this point by config.ts but the types can't know * that for sure at this point. */ static resolveFinalConfigForProject(releaseGroup, projectGraphNode, firstRelease, versionActionsOptionsOverrides) { const releaseGroupVersionConfig = releaseGroup.version; const projectVersionConfig = projectGraphNode.data.release?.version; const projectDockerConfig = projectGraphNode.data.release?.docker; /** * specifierSource * * If the user has provided a specifier, it always takes precedence, * so the effective specifier source is 'prompt', regardless of what * the project or release group config says. */ const specifierSource = projectVersionConfig?.specifierSource ?? releaseGroupVersionConfig?.specifierSource ?? 'prompt'; /** * versionPrefix, defaults to auto */ const versionPrefix = projectVersionConfig?.versionPrefix ?? releaseGroupVersionConfig?.versionPrefix ?? 'auto'; if (versionPrefix && !exports.validReleaseVersionPrefixes.includes(versionPrefix)) { throw new Error(`Invalid value for versionPrefix: "${versionPrefix}" Valid values are: ${exports.validReleaseVersionPrefixes .map((s) => `"${s}"`) .join(', ')}`); } /** * Merge docker options configured in project with release group config, * project level configuration should take precedence */ const dockerOptions = Object.assign({}, releaseGroup.docker || {}, projectDockerConfig || {}); /** * currentVersionResolver, defaults to disk */ let currentVersionResolver = projectVersionConfig?.currentVersionResolver ?? releaseGroupVersionConfig?.currentVersionResolver ?? 'disk'; // Check if this project should skip version actions based on docker configuration const shouldSkip = (0, shared_1.shouldSkipVersionActions)(dockerOptions, projectGraphNode.name); if (shouldSkip) { // If the project skips version actions, it doesn't need to resolve a current version currentVersionResolver = 'none'; } else if (specifierSource === 'conventional-commits' && currentVersionResolver !== 'git-tag') { throw new Error(`Invalid currentVersionResolver "${currentVersionResolver}" provided for project "${projectGraphNode.name}". Must be "git-tag" when "specifierSource" is "conventional-commits"`); } /** * currentVersionResolverMetadata, defaults to {} */ const currentVersionResolverMetadata = projectVersionConfig?.currentVersionResolverMetadata ?? releaseGroupVersionConfig?.currentVersionResolverMetadata ?? {}; /** * preserveLocalDependencyProtocols * * This was false by default in legacy versioning, but is true by default now. */ const preserveLocalDependencyProtocols = projectVersionConfig?.preserveLocalDependencyProtocols ?? releaseGroupVersionConfig?.preserveLocalDependencyProtocols ?? true; /** * preserveMatchingDependencyRanges * * This was false by default until v22, but is true by default now. */ const preserveMatchingDependencyRanges = projectVersionConfig?.preserveMatchingDependencyRanges ?? releaseGroupVersionConfig?.preserveMatchingDependencyRanges ?? true; /** * fallbackCurrentVersionResolver, defaults to disk when performing a first release, otherwise undefined */ const fallbackCurrentVersionResolver = projectVersionConfig?.fallbackCurrentVersionResolver ?? releaseGroupVersionConfig?.fallbackCurrentVersionResolver ?? (firstRelease ? 'disk' : undefined); /** * versionActionsOptions, defaults to {} */ let versionActionsOptions = projectVersionConfig?.versionActionsOptions ?? releaseGroupVersionConfig?.versionActionsOptions ?? {}; // Apply any optional overrides that may be passed in from the programmatic API versionActionsOptions = { ...versionActionsOptions, ...(versionActionsOptionsOverrides ?? {}), }; const manifestRootsToUpdate = (projectVersionConfig?.manifestRootsToUpdate ?? releaseGroupVersionConfig?.manifestRootsToUpdate ?? []).map((manifestRoot) => { if (typeof manifestRoot === 'string') { return { path: manifestRoot, // Apply the project level preserveLocalDependencyProtocols setting that was already resolved preserveLocalDependencyProtocols, }; } return manifestRoot; }); return { specifierSource, currentVersionResolver, currentVersionResolverMetadata, fallbackCurrentVersionResolver, versionPrefix, preserveLocalDependencyProtocols, preserveMatchingDependencyRanges, versionActionsOptions, manifestRootsToUpdate, dockerOptions, }; } /** * Get the release group for a given project */ getReleaseGroupForProject(projectName) { return this.projectToReleaseGroup.get(projectName); } /** * Get the release group name for a given project */ getReleaseGroupNameForProject(projectName) { const group = this.projectToReleaseGroup.get(projectName); return group ? group.name : null; } /** * Get the dependencies of a project */ getProjectDependencies(projectName) { return this.projectToDependencies.get(projectName) || new Set(); } /** * Get the dependents of a project (projects that depend on it) */ getProjectDependents(projectName) { return this.projectToDependents.get(projectName) || new Set(); } /** * Get the version actions for a project */ getVersionActionsForProject(projectName) { return this.projectsToVersionActions.get(projectName); } /** * Check if a project will be processed */ isProjectToProcess(projectName) { return this.allProjectsToProcess.has(projectName); } /** * Runs validation on resolved VersionActions instances. E.g. check that manifest files exist for all projects that will be processed. * This should be called after preVersionCommand has run, as those commands may create manifest files that are needed for versioning. */ async validate(tree) { const validationPromises = []; for (const projectName of this.allProjectsToProcess) { const versionActions = this.projectsToVersionActions.get(projectName); if (versionActions) { validationPromises.push(versionActions.validate(tree)); } } // Validate in parallel await Promise.all(validationPromises); } } exports.ReleaseGraph = ReleaseGraph; /** * Creates a complete release graph by analyzing all release groups, projects, and their relationships. * * This function builds the graph structure, applies filtering logic that considers dependencies * and updateDependents configuration, and caches all necessary data. * * The returned graph can be reused across versioning, changelog, and publishing operations. */ async function createReleaseGraph(options) { // Construct ReleaseGroupWithName objects from nxReleaseConfig const releaseGroups = Object.entries(options.nxReleaseConfig.groups).map(([name, group]) => { return { ...group, name, resolvedVersionPlans: group.versionPlans ? [] : false, }; }); const graph = new ReleaseGraph(releaseGroups, options.filters); await graph.init(options); return graph; }