Skip to content

Commit 7792d80

Browse files
authored
fix(core): sanitize project names for valid git tag names in nx release (#33692)
Gradle multi-module projects have project names with colons (e.g., `:common:iam-client`) which are invalid in git tag names. This adds a `sanitizeProjectNameForGitTag()` function that replaces colons with slashes and other invalid git ref characters with hyphens. The sanitization is applied when: - Creating git tags in `createGitTagValues()` - Creating the `ReleaseVersion` class gitTag property - Matching existing tags in `getLatestGitTagForPattern()` Fixes #33262
1 parent e6eed35 commit 7792d80

5 files changed

Lines changed: 232 additions & 8 deletions

File tree

packages/nx/src/command-line/release/utils/git.spec.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { extractReferencesFromCommit, getLatestGitTagForPattern } from './git';
1+
import {
2+
extractReferencesFromCommit,
3+
getLatestGitTagForPattern,
4+
sanitizeProjectNameForGitTag,
5+
} from './git';
26

37
jest.mock('./exec-command', () => ({
48
execCommand: jest.fn(() =>
@@ -26,6 +30,9 @@ [email protected]
2630
2731
2832
33+
release/common/iam-client/1.0.0
34+
release/apps/backend/api/2.0.0
35+
gradle/common/[email protected]
2936
`)
3037
),
3138
}));
@@ -297,6 +304,29 @@ See merge request nx-release-test/nx-release-test!2`,
297304
expectedVersion: '1.5.0',
298305
requireSemver: true,
299306
},
307+
// Gradle-style project names (sanitized before being passed to this function)
308+
// The caller (e.g., release-graph.ts) is responsible for sanitizing project names
309+
{
310+
pattern: 'release/{projectName}/{version}',
311+
projectName: sanitizeProjectNameForGitTag(':common:iam-client'), // Sanitized from Gradle-style
312+
expectedTag: 'release/common/iam-client/1.0.0',
313+
expectedVersion: '1.0.0',
314+
requireSemver: true,
315+
},
316+
{
317+
pattern: 'release/{projectName}/{version}',
318+
projectName: sanitizeProjectNameForGitTag(':apps:backend:api'), // Sanitized from nested Gradle module
319+
expectedTag: 'release/apps/backend/api/2.0.0',
320+
expectedVersion: '2.0.0',
321+
requireSemver: true,
322+
},
323+
{
324+
pattern: 'gradle/{projectName}@{version}',
325+
projectName: sanitizeProjectNameForGitTag(':common:lib'), // Sanitized
326+
expectedTag: 'gradle/common/[email protected]',
327+
expectedVersion: '1.5.0',
328+
requireSemver: true,
329+
},
300330
];
301331

302332
it.each(releaseTagPatternTestCases)(
@@ -455,4 +485,70 @@ See merge request nx-release-test/nx-release-test!2`,
455485
expect(result).toEqual(null);
456486
});
457487
});
488+
489+
describe('sanitizeProjectNameForGitTag', () => {
490+
it('should replace colons with slashes for Gradle-style module paths', () => {
491+
expect(
492+
sanitizeProjectNameForGitTag(':common:iam-enterprise-directory-client')
493+
).toBe('common/iam-enterprise-directory-client');
494+
});
495+
496+
it('should handle leading colon (Gradle root module indicator)', () => {
497+
expect(sanitizeProjectNameForGitTag(':my-module')).toBe('my-module');
498+
});
499+
500+
it('should handle multiple consecutive colons', () => {
501+
expect(sanitizeProjectNameForGitTag('a::b:::c')).toBe('a/b/c');
502+
});
503+
504+
it('should replace space with hyphen', () => {
505+
expect(sanitizeProjectNameForGitTag('my project')).toBe('my-project');
506+
});
507+
508+
it('should replace tilde with hyphen', () => {
509+
expect(sanitizeProjectNameForGitTag('my~project')).toBe('my-project');
510+
});
511+
512+
it('should replace caret with hyphen', () => {
513+
expect(sanitizeProjectNameForGitTag('my^project')).toBe('my-project');
514+
});
515+
516+
it('should replace question mark with hyphen', () => {
517+
expect(sanitizeProjectNameForGitTag('my?project')).toBe('my-project');
518+
});
519+
520+
it('should replace asterisk with hyphen', () => {
521+
expect(sanitizeProjectNameForGitTag('my*project')).toBe('my-project');
522+
});
523+
524+
it('should replace left bracket with hyphen', () => {
525+
expect(sanitizeProjectNameForGitTag('my[project')).toBe('my-project');
526+
});
527+
528+
it('should replace backslash with hyphen', () => {
529+
expect(sanitizeProjectNameForGitTag('my\\project')).toBe('my-project');
530+
});
531+
532+
it('should collapse consecutive dots', () => {
533+
expect(sanitizeProjectNameForGitTag('my..project')).toBe('my.project');
534+
});
535+
536+
it('should pass through valid project names unchanged', () => {
537+
expect(sanitizeProjectNameForGitTag('my-valid-project')).toBe(
538+
'my-valid-project'
539+
);
540+
expect(sanitizeProjectNameForGitTag('my_valid_project')).toBe(
541+
'my_valid_project'
542+
);
543+
expect(sanitizeProjectNameForGitTag('my.valid.project')).toBe(
544+
'my.valid.project'
545+
);
546+
});
547+
548+
it('should handle complex Gradle multi-module names', () => {
549+
expect(sanitizeProjectNameForGitTag(':apps:backend:api-service')).toBe(
550+
'apps/backend/api-service'
551+
);
552+
});
553+
});
458554
});

packages/nx/src/command-line/release/utils/git.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,44 @@ function escapeRegExp(string) {
5757
const SEMVER_REGEX =
5858
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/g;
5959

60+
/**
61+
* Characters that are invalid in git ref names according to git-check-ref-format.
62+
* Note: We don't include ':' here as we handle it specially (replace with '/').
63+
*/
64+
const GIT_INVALID_REF_CHARS_REGEX = /[\x00-\x1f\x7f ~^?*\[\\]/g;
65+
66+
/**
67+
* Sanitizes a project name to be valid for use in git tag names.
68+
*
69+
* Git tag names have specific restrictions per git-check-ref-format.
70+
* This function handles:
71+
* - Colons (:) - replaced with slashes (/) for Gradle-style module paths
72+
* - Other invalid characters - replaced with hyphens (-)
73+
* - Consecutive slashes - collapsed to single slash
74+
* - Leading/trailing slashes - removed
75+
* - Consecutive dots - replaced with single dot
76+
*
77+
* @param name - The project name to sanitize
78+
* @returns The sanitized name suitable for git tags
79+
*/
80+
export function sanitizeProjectNameForGitTag(name: string): string {
81+
return (
82+
name
83+
// Replace colons with slashes (for Gradle module paths like :common:lib)
84+
.replace(/:/g, '/')
85+
// Replace other git-invalid characters with hyphens
86+
.replace(GIT_INVALID_REF_CHARS_REGEX, '-')
87+
// Collapse consecutive slashes to single slash
88+
.replace(/\/+/g, '/')
89+
// Collapse consecutive dots (invalid in git refs)
90+
.replace(/\.{2,}/g, '.')
91+
// Remove leading slashes
92+
.replace(/^\/+/, '')
93+
// Remove trailing slashes
94+
.replace(/\/+$/, '')
95+
);
96+
}
97+
6098
/**
6199
* Extract the tag and version from a tag string
62100
*

packages/nx/src/command-line/release/utils/release-graph.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
type AfterAllProjectsVersioned,
2222
type VersionActions,
2323
} from '../version/version-actions';
24-
import { getLatestGitTagForPattern } from './git';
24+
import { getLatestGitTagForPattern, sanitizeProjectNameForGitTag } from './git';
2525
import { shouldSkipVersionActions, type VersionDataEntry } from './shared';
2626

2727
/**
@@ -627,7 +627,7 @@ export class ReleaseGraph {
627627
latestMatchingGitTag = await getLatestGitTagForPattern(
628628
releaseTagPattern,
629629
{
630-
projectName: projectGraphNode.name,
630+
projectName: sanitizeProjectNameForGitTag(projectGraphNode.name),
631631
releaseGroupName: releaseGroupNode.group.name,
632632
},
633633
{

packages/nx/src/command-line/release/utils/shared.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,89 @@ describe('shared', () => {
457457
]);
458458
});
459459

460+
it('should sanitize Gradle-style project names with colons in independent groups', () => {
461+
const projects = [':common:iam-client'];
462+
const releaseGroup: ReleaseGroupWithName = {
463+
name: 'gradle-group',
464+
projects,
465+
projectsRelationship: 'independent',
466+
releaseTag: {
467+
pattern: 'release/{projectName}/{version}',
468+
checkAllBranchesWhen: undefined,
469+
requireSemver: true,
470+
preferDockerVersion: undefined,
471+
strictPreid: false,
472+
},
473+
changelog: undefined,
474+
version: undefined,
475+
versionPlans: false,
476+
resolvedVersionPlans: false,
477+
};
478+
479+
const releaseGroupToFilteredProjects = new Map<
480+
ReleaseGroupWithName,
481+
Set<string>
482+
>().set(releaseGroup, new Set(projects));
483+
484+
const versionData = {
485+
':common:iam-client': {
486+
currentVersion: '1.0.0',
487+
dependentProjects: [],
488+
newVersion: '1.0.1',
489+
},
490+
};
491+
492+
const tags = createGitTagValues(
493+
[releaseGroup],
494+
releaseGroupToFilteredProjects,
495+
versionData
496+
);
497+
498+
// Colons should be replaced with slashes
499+
expect(tags).toEqual(['release/common/iam-client/1.0.1']);
500+
});
501+
502+
it('should sanitize nested Gradle-style project names', () => {
503+
const projects = [':apps:backend:api-service'];
504+
const releaseGroup: ReleaseGroupWithName = {
505+
name: 'gradle-group',
506+
projects,
507+
projectsRelationship: 'independent',
508+
releaseTag: {
509+
pattern: '{projectName}@{version}',
510+
checkAllBranchesWhen: undefined,
511+
requireSemver: true,
512+
preferDockerVersion: undefined,
513+
strictPreid: false,
514+
},
515+
changelog: undefined,
516+
version: undefined,
517+
versionPlans: false,
518+
resolvedVersionPlans: false,
519+
};
520+
521+
const releaseGroupToFilteredProjects = new Map<
522+
ReleaseGroupWithName,
523+
Set<string>
524+
>().set(releaseGroup, new Set(projects));
525+
526+
const versionData = {
527+
':apps:backend:api-service': {
528+
currentVersion: '2.0.0',
529+
dependentProjects: [],
530+
newVersion: '2.1.0',
531+
},
532+
};
533+
534+
const tags = createGitTagValues(
535+
[releaseGroup],
536+
releaseGroupToFilteredProjects,
537+
versionData
538+
);
539+
540+
expect(tags).toEqual(['apps/backend/[email protected]']);
541+
});
542+
460543
function setUpReleaseGroup() {
461544
const projects = ['a', 'b'];
462545
const releaseGroup: ReleaseGroupWithName = {

packages/nx/src/command-line/release/utils/shared.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import { interpolate } from '../../../tasks-runner/utils';
1212
import type { NxArgs } from '../../../utils/command-line-utils';
1313
import { output } from '../../../utils/output';
1414
import type { ReleaseGroupWithName } from '../config/filter-release-groups';
15-
import { GitCommit, gitAdd, gitCommit } from './git';
15+
import {
16+
GitCommit,
17+
gitAdd,
18+
gitCommit,
19+
sanitizeProjectNameForGitTag,
20+
} from './git';
1621
import { NxReleaseConfig } from '../config/config';
1722

1823
export const noDiffInChangelogMessage = chalk.yellow(
@@ -73,7 +78,9 @@ export class ReleaseVersion {
7378
this.rawVersion = version;
7479
this.gitTag = interpolate(releaseTagPattern, {
7580
version,
76-
projectName,
81+
projectName: projectName
82+
? sanitizeProjectNameForGitTag(projectName)
83+
: projectName,
7784
});
7885
this.isPrerelease = isPrerelease(version);
7986
}
@@ -288,15 +295,15 @@ export function createGitTagValues(
288295
tags.push(
289296
interpolate(releaseGroup.releaseTag.pattern, {
290297
version: projectVersionData.dockerVersion,
291-
projectName: project,
298+
projectName: sanitizeProjectNameForGitTag(project),
292299
})
293300
);
294301
}
295302
if (projectVersionData.newVersion) {
296303
tags.push(
297304
interpolate(releaseGroup.releaseTag.pattern, {
298305
version: projectVersionData.newVersion,
299-
projectName: project,
306+
projectName: sanitizeProjectNameForGitTag(project),
300307
})
301308
);
302309
}
@@ -307,7 +314,7 @@ export function createGitTagValues(
307314
version: preferDockerVersion
308315
? projectVersionData.dockerVersion
309316
: projectVersionData.newVersion,
310-
projectName: project,
317+
projectName: sanitizeProjectNameForGitTag(project),
311318
})
312319
);
313320
}

0 commit comments

Comments
 (0)