diff --git a/packages/nx/src/command-line/release/utils/release-graph.ts b/packages/nx/src/command-line/release/utils/release-graph.ts index d0182244a68e1..26b914d8db613 100644 --- a/packages/nx/src/command-line/release/utils/release-graph.ts +++ b/packages/nx/src/command-line/release/utils/release-graph.ts @@ -820,17 +820,33 @@ Valid values are: ${validReleaseVersionPrefixes /** * Merge docker options configured in project with release group config, - * project level configuration should take precedence + * project level configuration should take precedence. + * + * Only apply docker options if: + * 1. Project has explicit project-level docker config, OR + * 2. Project has the nx-release-publish target with @nx/docker:release-publish executor + * + * This prevents all projects in a group from inheriting docker options + * when only some projects should be dockerized. We specifically check for + * the nx-release-publish target as this is what the @nx/docker plugin creates + * for all docker projects. */ + const projectHasDockerReleaseTarget = + projectGraphNode.data.targets?.['nx-release-publish']?.executor === + '@nx/docker:release-publish'; + + const shouldApplyDockerOptions = + projectHasDockerReleaseTarget || projectDockerConfig; + const dockerOptions: NxReleaseDockerConfiguration & { groupPreVersionCommand?: string; - } = Object.assign( - {}, - releaseGroup.docker || {}, - projectDockerConfig || {} - ) as NxReleaseDockerConfiguration & { - groupPreVersionCommand?: string; - }; + } = shouldApplyDockerOptions + ? Object.assign( + {}, + releaseGroup.docker || {}, + projectDockerConfig || {} + ) + : {}; /** * currentVersionResolver, defaults to disk diff --git a/packages/nx/src/command-line/release/version/release-group-processor.spec.ts b/packages/nx/src/command-line/release/version/release-group-processor.spec.ts index 5825d03d14b28..fb31396d3ffe7 100644 --- a/packages/nx/src/command-line/release/version/release-group-processor.spec.ts +++ b/packages/nx/src/command-line/release/version/release-group-processor.spec.ts @@ -1866,4 +1866,205 @@ describe('ReleaseGroupProcessor', () => { `); }); }); + + describe('docker project filtering', () => { + beforeEach(() => { + // Set dry-run mode to avoid actual docker command execution in tests + process.env.NX_DRY_RUN = 'true'; + }); + + afterEach(() => { + // Clean up dry-run mode + delete process.env.NX_DRY_RUN; + }); + + it('should only process projects with docker targets when group has docker config', async () => { + const { nxReleaseConfig, projectGraph, filters } = + await createNxReleaseConfigAndPopulateWorkspace( + tree, + ` + __default__ ({ "projectsRelationship": "independent", "docker": { "preVersionCommand": "npx nx run-many -t docker:build", "versionSchemes": { "production": "{currentDate|YYMM.DD}.{shortCommitSha}" } } }): + - api@1.0.0 [js] ({ "targets": { "nx-release-publish": { "executor": "@nx/docker:release-publish" } } }) + - web-app@1.0.0 [js] ({ "targets": { "nx-release-publish": { "executor": "@nx/docker:release-publish" } } }) + - shared-lib@1.0.0 [js] + `, + { + version: { + conventionalCommits: true, + }, + }, + mockResolveCurrentVersion + ); + + const processor = await createTestReleaseGroupProcessor( + tree, + projectGraph, + nxReleaseConfig, + filters + ); + + // Check that only docker projects got dockerOptions + const apiConfig = + processor['releaseGraph'].finalConfigsByProject.get('api'); + const webAppConfig = + processor['releaseGraph'].finalConfigsByProject.get('web-app'); + const sharedLibConfig = + processor['releaseGraph'].finalConfigsByProject.get('shared-lib'); + + // api and web-app should have docker options + expect(apiConfig).toBeDefined(); + expect(webAppConfig).toBeDefined(); + expect(Object.keys(apiConfig!.dockerOptions).length).toBeGreaterThan(0); + expect(Object.keys(webAppConfig!.dockerOptions).length).toBeGreaterThan( + 0 + ); + + // shared-lib should NOT have docker options (empty object) + expect(sharedLibConfig).toBeDefined(); + expect(Object.keys(sharedLibConfig!.dockerOptions).length).toBe(0); + }); + + it('should not apply docker options to projects without docker targets', async () => { + const { nxReleaseConfig, projectGraph, filters } = + await createNxReleaseConfigAndPopulateWorkspace( + tree, + ` + __default__ ({ "projectsRelationship": "independent", "docker": { "preVersionCommand": "npx nx run-many -t docker:build", "skipVersionActions": ["docker-app"] } }): + - docker-app@1.0.0 [js] ({ "targets": { "docker:build": { "executor": "@nx/docker:build" }, "nx-release-publish": { "executor": "@nx/docker:release-publish" } } }) + - npm-package@1.0.0 [js] + `, + { + version: { + conventionalCommits: true, + }, + }, + mockResolveCurrentVersion + ); + + const processor = await createTestReleaseGroupProcessor( + tree, + projectGraph, + nxReleaseConfig, + filters + ); + + // Check finalConfigsByProject to ensure npm-package doesn't have docker options + const dockerAppConfig = + processor['releaseGraph'].finalConfigsByProject.get('docker-app'); + const npmPackageConfig = + processor['releaseGraph'].finalConfigsByProject.get('npm-package'); + + // docker-app should have docker options + expect(dockerAppConfig).toBeDefined(); + expect( + Object.keys(dockerAppConfig!.dockerOptions).length + ).toBeGreaterThan(0); + + // npm-package should NOT have docker options (empty object) + expect(npmPackageConfig).toBeDefined(); + expect(Object.keys(npmPackageConfig!.dockerOptions).length).toBe(0); + }); + + it('should handle mixed release group with npm and docker projects correctly', async () => { + const { nxReleaseConfig, projectGraph, filters } = + await createNxReleaseConfigAndPopulateWorkspace( + tree, + ` + backend-apps ({ "projectsRelationship": "fixed", "docker": { "preVersionCommand": "npx nx affected -t docker:build", "versionSchemes": { "production": "{currentDate|YYMM.DD}.{shortCommitSha}" } } }): + - api-gateway@1.0.0 [js] ({ "targets": { "docker:build": { "executor": "@nx/docker:build" }, "nx-release-publish": { "executor": "@nx/docker:release-publish" } } }) + - auth-service@1.0.0 [js] ({ "targets": { "docker:build": { "executor": "@nx/docker:build" }, "nx-release-publish": { "executor": "@nx/docker:release-publish" } } }) + - shared-utils@1.0.0 [js] + `, + { + version: { + conventionalCommits: true, + }, + }, + mockResolveCurrentVersion + ); + + const processor = await createTestReleaseGroupProcessor( + tree, + projectGraph, + nxReleaseConfig, + filters + ); + + // Verify that only docker projects got dockerOptions + const apiGatewayConfig = + processor['releaseGraph'].finalConfigsByProject.get('api-gateway'); + const authServiceConfig = + processor['releaseGraph'].finalConfigsByProject.get('auth-service'); + const sharedUtilsConfig = + processor['releaseGraph'].finalConfigsByProject.get('shared-utils'); + + expect(apiGatewayConfig).toBeDefined(); + expect(authServiceConfig).toBeDefined(); + expect(sharedUtilsConfig).toBeDefined(); + + // Docker projects should have docker options + expect( + Object.keys(apiGatewayConfig!.dockerOptions).length + ).toBeGreaterThan(0); + expect( + Object.keys(authServiceConfig!.dockerOptions).length + ).toBeGreaterThan(0); + + // shared-utils should NOT have docker options (empty object) + expect(Object.keys(sharedUtilsConfig!.dockerOptions).length).toBe(0); + }); + + it('should skip docker projects that have no new version (no changes)', async () => { + const { nxReleaseConfig, projectGraph, filters } = + await createNxReleaseConfigAndPopulateWorkspace( + tree, + ` + __default__ ({ "projectsRelationship": "independent", "docker": { "preVersionCommand": "npx nx run-many -t docker:build", "versionSchemes": { "production": "{currentDate|YYMM.DD}.{shortCommitSha}" } } }): + - app-with-changes@1.0.0 [js] ({ "targets": { "nx-release-publish": { "executor": "@nx/docker:release-publish" } } }) + - app-no-changes@1.0.0 [js] ({ "targets": { "nx-release-publish": { "executor": "@nx/docker:release-publish" } } }) + `, + { + version: { + conventionalCommits: true, + }, + }, + mockResolveCurrentVersion + ); + + const processor = await createTestReleaseGroupProcessor( + tree, + projectGraph, + nxReleaseConfig, + filters + ); + + // Simulate that only app-with-changes has a new version + // Set up mock BEFORE calling processGroups + mockDeriveSpecifierFromConventionalCommits.mockImplementation( + (_, __, ___, ____, { name: projectName }) => { + if (projectName === 'app-with-changes') { + return 'patch'; + } + return 'none'; // app-no-changes has no changes + } + ); + + await processor.processGroups(); + + // Verify that processDockerProjects doesn't throw when a project has no new version + await processor.processDockerProjects('production', '2024.10.test'); + + const versionData = processor.getVersionData(); + + // app-with-changes should have both semver and docker version + expect(versionData['app-with-changes'].newVersion).toBe('1.0.1'); + expect(versionData['app-with-changes'].dockerVersion).toBe( + '2024.10.test' + ); + + // app-no-changes should have neither (newVersion is null, dockerVersion stays null) + expect(versionData['app-no-changes'].newVersion).toBeNull(); + expect(versionData['app-no-changes'].dockerVersion).toBeNull(); + }); + }); }); diff --git a/packages/nx/src/command-line/release/version/release-group-processor.ts b/packages/nx/src/command-line/release/version/release-group-processor.ts index e0aad51a1cbc3..cebedfbaa8fcb 100644 --- a/packages/nx/src/command-line/release/version/release-group-processor.ts +++ b/packages/nx/src/command-line/release/version/release-group-processor.ts @@ -266,6 +266,28 @@ export class ReleaseGroupProcessor { if (Object.keys(finalConfigForProject.dockerOptions).length === 0) { continue; } + + // Additionally verify that the project actually has the docker release target + // This prevents projects without docker capability from being processed + // even if they inherited group-level docker config. + // We specifically check for nx-release-publish with @nx/docker:release-publish + // executor as this is what the @nx/docker plugin creates for docker projects. + const projectNode = this.projectGraph.nodes[project]; + const hasDockerReleaseTarget = + projectNode.data.targets?.['nx-release-publish']?.executor === + '@nx/docker:release-publish'; + + if (!hasDockerReleaseTarget) { + continue; + } + + // Skip projects that don't have a new version (no changes) + // This prevents docker tag failures when the version is empty + const projectVersionData = this.versionData.get(project); + if (!projectVersionData.newVersion) { + continue; + } + dockerProjects.set(project, finalConfigForProject); } // If no docker projects to process, exit early to avoid unnecessary loading of docker handling diff --git a/packages/nx/src/command-line/release/version/test-utils.spec.ts b/packages/nx/src/command-line/release/version/test-utils.spec.ts index 7114433949fb8..f6bcbae20798f 100644 --- a/packages/nx/src/command-line/release/version/test-utils.spec.ts +++ b/packages/nx/src/command-line/release/version/test-utils.spec.ts @@ -15,6 +15,11 @@ describe('parseGraphDefinition', () => { ); expect(testGraph).toMatchInlineSnapshot(` { + "groupConfigs": { + "__default__": { + "projectsRelationship": "fixed", + }, + }, "projects": { "projectD": { "alternateNameInManifest": undefined, @@ -81,6 +86,11 @@ describe('parseGraphDefinition', () => { ); expect(testGraph).toMatchInlineSnapshot(` { + "groupConfigs": { + "__default__": { + "projectsRelationship": "independent", + }, + }, "projects": { "projectA": { "alternateNameInManifest": undefined, @@ -123,6 +133,11 @@ describe('parseGraphDefinition', () => { ); expect(testGraph).toMatchInlineSnapshot(` { + "groupConfigs": { + "__default__": { + "projectsRelationship": "independent", + }, + }, "projects": { "projectA": { "alternateNameInManifest": undefined, diff --git a/packages/nx/src/command-line/release/version/test-utils.ts b/packages/nx/src/command-line/release/version/test-utils.ts index 21be205de9e2a..c49e81c9be230 100644 --- a/packages/nx/src/command-line/release/version/test-utils.ts +++ b/packages/nx/src/command-line/release/version/test-utils.ts @@ -324,7 +324,7 @@ export class ExampleNonSemverVersionActions extends VersionActions { } export function parseGraphDefinition(definition: string) { - const graph = { projects: {} as any }; + const graph = { projects: {} as any, groupConfigs: {} as any }; const lines = definition.trim().split('\n'); let currentGroup = ''; let groupConfig = {}; @@ -340,11 +340,13 @@ export function parseGraphDefinition(definition: string) { } // Match group definitions with JSON config - const groupMatch = line.match(/^(\w+)\s*\(\s*(\{.*?\})\s*\):$/); + const groupMatch = line.match(/^([\w-]+)\s*\(\s*(\{.*\})\s*\):$/); if (groupMatch) { currentGroup = groupMatch[1]; groupConfig = JSON.parse(groupMatch[2]); groupRelationship = groupConfig['projectsRelationship'] || 'independent'; + // Store the full group config + graph.groupConfigs[currentGroup] = groupConfig; return; } @@ -592,7 +594,10 @@ function setupGraph(tree: any, graph: any) { // Add to releaseGroups if (!groups[group]) { + // Use stored groupConfig if available, otherwise create default config + const storedGroupConfig = graph.groupConfigs?.[group] || {}; groups[group] = { + ...storedGroupConfig, projectsRelationship: relationship, projects: [], } as any;