Skip to content

Commit ad08925

Browse files
ekkolonFrozenPandaz
authored andcommitted
fix(core): support canonical SSH URLs when extracting GitHub user/repo slug during nx release (#31684)
## Current Behavior When running `nx release` with Git remotes configured as **canonical SSH URLs**, the repository slug is extracted incorrectly. Examples of affected remotes include: - `ssh://[email protected]:443/org/repo.git` - `ssh://[email protected]:2222/group/subgroup/repo.git` In these cases, the existing regex-based logic misinterprets the port number as part of the repository path (for example: `443/org`). This causes the release creation request to be built with an invalid repository slug and results in `404 Not Found` errors from the GitHub or GitLab APIs. ## Expected Behavior `nx release` should correctly extract the repository slug from all valid Git remote URL formats, regardless of whether the remote uses HTTPS, SCP-style SSH, or fully qualified SSH URLs with explicit ports. Valid examples should resolve to the correct slug: - `ssh://[email protected]:443/org/repo.git` => `org/repo` - `ssh://[email protected]:2222/group/subgroup/repo.git`=> `group/subgroup/repo` Users should not need to modify their Git remote configuration in order for `nx release` to work correctly. ## What’s Changed - Introduced a shared utility ([`extractRepoSlug`](https://github.com/nrwl/nx/pull/31684/changes#diff-799188c178b8a084e86dbe9063ec88a7c324212a8ef79729777f56e4bf7f455cR29-R60)) to consistently extract repository slugs from Git remote URLs. - Replaced provider-specific, regex-based parsing in: - `GithubRemoteReleaseClient` - `GitLabRemoteReleaseClient` - Added support for: - HTTPS remotes - SCP-style SSH remotes (`git@host:org/repo.git`) - Fully qualified SSH URLs with custom ports - Arbitrarily nested GitLab group and subgroup paths - Self-hosted GitHub and GitLab instances via hostname matching - Added comprehensive unit tests covering valid and invalid URL formats for both providers. ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes #31682 (cherry picked from commit 8255c28)
1 parent d7c759c commit ad08925

4 files changed

Lines changed: 191 additions & 29 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {
2+
extractGitHubRepoSlug,
3+
extractGitLabRepoSlug,
4+
} from './extract-repo-slug';
5+
6+
describe('extractGitHubRepoSlug', () => {
7+
describe('valid GitHub URLs', () => {
8+
it.each([
9+
['https://github.com/user/repo.git', 'user/repo'],
10+
['https://github.com/user/repo', 'user/repo'],
11+
['[email protected]:user/repo.git', 'user/repo'],
12+
['[email protected]:user/repo', 'user/repo'],
13+
['ssh://[email protected]/user/repo.git', 'user/repo'],
14+
['ssh://[email protected]:443/user/repo', 'user/repo'],
15+
['https://[email protected]/user/repo.git', 'user/repo'],
16+
['https://github.com/user/repo.git?ref=main', 'user/repo'],
17+
['https://github.com/user/repo.git#readme', 'user/repo'],
18+
])('parses %s → %s', (url, expected) => {
19+
expect(extractGitHubRepoSlug(url, 'github.com')).toBe(expected);
20+
});
21+
});
22+
23+
describe('invalid/mismatched GitHub URLs', () => {
24+
it.each([
25+
['https://gitlab.com/user/repo.git'],
26+
['[email protected]:user/repo.git'],
27+
['not-a-url'],
28+
[''],
29+
['https://github.com/user'], // only 1 segment
30+
['https://github.com/'], // no segments
31+
['https://github.com/.git'],
32+
])('returns null for %s', (url) => {
33+
expect(extractGitHubRepoSlug(url, 'github.com')).toBeNull();
34+
});
35+
});
36+
});
37+
38+
describe('extractGitLabRepoSlug', () => {
39+
describe('valid GitLab URLs with subgroups', () => {
40+
it.each([
41+
['https://gitlab.com/user/repo.git', 'user/repo'],
42+
['https://gitlab.com/user/repo', 'user/repo'],
43+
['https://gitlab.com/group/subgroup/repo.git', 'group/subgroup/repo'],
44+
['https://gitlab.com/group/subgroup/repo', 'group/subgroup/repo'],
45+
['[email protected]:group/subgroup/repo.git', 'group/subgroup/repo'],
46+
['[email protected]:group/subgroup/repo', 'group/subgroup/repo'],
47+
['ssh://[email protected]/group/subgroup/repo.git', 'group/subgroup/repo'],
48+
[
49+
'ssh://[email protected]:22/group/subgroup/repo.git',
50+
'group/subgroup/repo',
51+
],
52+
[
53+
'https://[email protected]/group/subgroup/repo.git',
54+
'group/subgroup/repo',
55+
],
56+
[
57+
'https://gitlab.com/group/subgroup/repo.git?ref=main',
58+
'group/subgroup/repo',
59+
],
60+
[
61+
'https://gitlab.com/group/subgroup/repo.git#readme',
62+
'group/subgroup/repo',
63+
],
64+
])('parses %s → %s', (url, expected) => {
65+
expect(extractGitLabRepoSlug(url, 'gitlab.com')).toBe(expected);
66+
});
67+
68+
it('supports deeply nested slugs', () => {
69+
const url = 'https://gitlab.com/org/team/subteam/subproject/project.git';
70+
expect(extractGitLabRepoSlug(url, 'gitlab.com')).toBe(
71+
'org/team/subteam/subproject/project'
72+
);
73+
});
74+
});
75+
76+
describe('invalid/mismatched GitLab URLs', () => {
77+
it.each([
78+
['https://github.com/user/repo.git'],
79+
['[email protected]:user/repo.git'],
80+
['not-a-url'],
81+
[''],
82+
['https://gitlab.com/user'],
83+
['https://gitlab.com/'],
84+
['https://gitlab.com/.git'],
85+
])('returns null for %s', (url) => {
86+
expect(extractGitLabRepoSlug(url, 'gitlab.com')).toBeNull();
87+
});
88+
});
89+
90+
describe('self-hosted GitLab', () => {
91+
it.each([
92+
['https://gitlab.company.com/group/repo.git', 'group/repo'],
93+
['[email protected]:group/repo.git', 'group/repo'],
94+
['ssh://[email protected]/group/repo.git', 'group/repo'],
95+
])('extracts valid repo from %s', (url, expected) => {
96+
expect(extractGitLabRepoSlug(url, 'gitlab.company.com')).toBe(expected);
97+
});
98+
99+
it('returns null on host mismatch', () => {
100+
const url = 'https://gitlab.company-a.com/group/repo.git';
101+
expect(extractGitLabRepoSlug(url, 'gitlab.company-b.com')).toBeNull();
102+
});
103+
});
104+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { RemoteRepoSlug } from './remote-release-client';
2+
3+
/**
4+
* Extracts a GitHub-style repo slug (user/repo).
5+
*/
6+
export function extractGitHubRepoSlug(
7+
remoteUrl: string,
8+
expectedHostname: string
9+
): RemoteRepoSlug | null {
10+
return extractRepoSlug(remoteUrl, expectedHostname, 2);
11+
}
12+
13+
/**
14+
* Extracts a GitLab-style repo slug with full nested group path.
15+
*/
16+
export function extractGitLabRepoSlug(
17+
remoteUrl: string,
18+
expectedHostname: string
19+
): RemoteRepoSlug | null {
20+
return extractRepoSlug(remoteUrl, expectedHostname, Infinity);
21+
}
22+
23+
const SCP_URL_REGEX = /^git@([^:]+):(.+)$/;
24+
25+
/**
26+
* Extracts a repository slug from a Git remote URL.
27+
* `segmentLimit` = 2 for GitHub (user/repo), `Infinity` for GitLab (with subgroups).
28+
*/
29+
function extractRepoSlug(
30+
remoteUrl: string,
31+
expectedHostname: string,
32+
segmentLimit: number
33+
): RemoteRepoSlug | null {
34+
if (!remoteUrl) return null;
35+
36+
// SCP-like: git@host:path
37+
const scpMatch = remoteUrl.match(SCP_URL_REGEX);
38+
if (scpMatch) {
39+
const [, host, path] = scpMatch;
40+
if (!isHostMatch(host, expectedHostname)) return null;
41+
42+
const segments = normalizeRepoPath(path).split('/').filter(Boolean);
43+
if (segments.length < 2) return null;
44+
45+
return segments.slice(0, segmentLimit).join('/') as RemoteRepoSlug;
46+
}
47+
48+
// URL-like
49+
try {
50+
const url = new URL(remoteUrl);
51+
if (!isHostMatch(url.hostname, expectedHostname)) return null;
52+
53+
const segments = normalizeRepoPath(url.pathname).split('/').filter(Boolean);
54+
if (segments.length < 2) return null;
55+
56+
return segments.slice(0, segmentLimit).join('/') as RemoteRepoSlug;
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
function normalizeRepoPath(s: string): string {
63+
return s.replace(/^\/+|\/+$|\.git$/g, '');
64+
}
65+
66+
function normalizeHostname(hostname: string): string {
67+
return hostname
68+
.toLowerCase()
69+
.replace(/^ssh\./, '')
70+
.split(':')[0];
71+
}
72+
73+
function isHostMatch(actual: string, expected: string): boolean {
74+
return normalizeHostname(actual) === normalizeHostname(expected);
75+
}

packages/nx/src/command-line/release/utils/remote-release-clients/github.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import type { PostGitTask } from '../../changelog';
99
import { type ResolvedCreateRemoteReleaseProvider } from '../../config/config';
1010
import { Reference } from '../git';
1111
import { ReleaseVersion } from '../shared';
12+
import { extractGitHubRepoSlug } from './extract-repo-slug';
1213
import {
1314
RemoteReleaseClient,
1415
RemoteReleaseOptions,
1516
RemoteReleaseResult,
1617
RemoteRepoData,
17-
RemoteRepoSlug,
1818
} from './remote-release-client';
1919

2020
// axios types and values don't seem to match
@@ -66,29 +66,22 @@ export class GithubRemoteReleaseClient extends RemoteReleaseClient<GithubRemoteR
6666
createReleaseConfig !== false &&
6767
typeof createReleaseConfig !== 'string'
6868
) {
69-
hostname = createReleaseConfig.hostname;
69+
hostname = createReleaseConfig.hostname || hostname;
7070
apiBaseUrl = createReleaseConfig.apiBaseUrl;
7171
}
7272

73-
// Extract the 'user/repo' part from the URL
74-
const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
75-
const regexString = `${escapedHostname}[/:]([\\w.-]+/[\\w.-]+)(\\.git)?`;
76-
const regex = new RegExp(regexString);
77-
const match = remoteUrl.match(regex);
78-
79-
if (match && match[1]) {
80-
return {
81-
hostname,
82-
apiBaseUrl,
83-
// Ensure any trailing .git is stripped
84-
slug: match[1].replace(/\.git$/, '') as RemoteRepoSlug,
85-
};
73+
const slug = extractGitHubRepoSlug(remoteUrl, hostname);
74+
if (slug) {
75+
return { hostname, apiBaseUrl, slug };
8676
} else {
8777
throw new Error(
8878
`Could not extract "user/repo" data from the resolved remote URL: ${remoteUrl}`
8979
);
9080
}
9181
} catch (error) {
82+
if (process.env.NX_VERBOSE_LOGGING === 'true') {
83+
console.error(error);
84+
}
9285
return null;
9386
}
9487
}

packages/nx/src/command-line/release/utils/remote-release-clients/gitlab.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import type { PostGitTask } from '../../changelog';
66
import type { ResolvedCreateRemoteReleaseProvider } from '../../config/config';
77
import type { Reference } from '../git';
88
import { ReleaseVersion } from '../shared';
9+
import { extractGitLabRepoSlug } from './extract-repo-slug';
910
import {
1011
RemoteReleaseClient,
1112
RemoteReleaseOptions,
1213
RemoteReleaseResult,
1314
RemoteRepoData,
14-
RemoteRepoSlug,
1515
} from './remote-release-client';
1616

1717
export interface GitLabRepoData extends RemoteRepoData {
@@ -64,28 +64,18 @@ export class GitLabRemoteReleaseClient extends RemoteReleaseClient<GitLabRelease
6464
// Use the default provider if custom one is not specified or releases are disabled
6565
let hostname = defaultCreateReleaseProvider.hostname;
6666
let apiBaseUrl = defaultCreateReleaseProvider.apiBaseUrl;
67-
6867
if (
6968
createReleaseConfig !== false &&
7069
typeof createReleaseConfig !== 'string'
7170
) {
7271
hostname = createReleaseConfig.hostname || hostname;
73-
apiBaseUrl = createReleaseConfig.apiBaseUrl || apiBaseUrl;
72+
apiBaseUrl = createReleaseConfig.apiBaseUrl;
7473
}
7574

76-
// Extract the project path from the URL
77-
const escapedHostname = hostname.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
78-
const regexString = `${escapedHostname}[/:]([\\w.-]+/[\\w.-]+(?:/[\\w.-]+)*)(\\.git)?`;
79-
const regex = new RegExp(regexString);
80-
const match = remoteUrl.match(regex);
81-
82-
if (match && match[1]) {
83-
// Remove trailing .git if present
84-
const slug = match[1].replace(/\.git$/, '') as RemoteRepoSlug;
85-
75+
const slug = extractGitLabRepoSlug(remoteUrl, hostname);
76+
if (slug) {
8677
// Encode the project path for use in API URLs
8778
const projectId = encodeURIComponent(slug);
88-
8979
return {
9080
hostname,
9181
apiBaseUrl,

0 commit comments

Comments
 (0)