Skip to content

Commit 6d9b577

Browse files
authored
feat: add support for semantic version ranges for components (#1534)
1 parent 1c82b22 commit 6d9b577

File tree

8 files changed

+248
-9
lines changed

8 files changed

+248
-9
lines changed

package-lock.json

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"p-map": "4.x.x",
4343
"pretty-hrtime": "1.x.x",
4444
"re2": "^1.21.4",
45+
"semver": "7.x.x",
4546
"split2": "4.x.x",
4647
"terminal-link": "2.1.1",
4748
"yargs": "17.x.x"
@@ -60,6 +61,7 @@
6061
"@types/micromatch": "4.x.x",
6162
"@types/node": "22.x",
6263
"@types/pretty-hrtime": "1.x.x",
64+
"@types/semver": "7.x.x",
6365
"@types/split2": "4.x.x",
6466
"@types/yargs": "17.x.x",
6567
"@yao-pkg/pkg": "^6.0.0",

src/parser-includes.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {Parser} from "./parser.js";
99
import axios from "axios";
1010
import globby from "globby";
1111
import path from "path";
12+
import semver from "semver";
1213

1314
type ParserIncludesInitOptions = {
1415
argv: Argv;
@@ -128,10 +129,9 @@ export class ParserIncludes {
128129
includeDatas = includeDatas.concat(await this.init(fileDoc, opts));
129130
}
130131
} else if (value["component"]) {
131-
const {domain, port, projectPath, componentName, ref} = this.parseIncludeComponent(value["component"]);
132+
const {domain, port, projectPath, componentName, ref, isLocalComponent} = this.parseIncludeComponent(value["component"], gitData);
132133
// converts component to project. gitlab allows two different file path ways to include a component
133134
let files = [`${componentName}.yml`, `${componentName}/template.yml`, null];
134-
const isLocalComponent = projectPath === `${gitData.remote.group}/${gitData.remote.project}` && ref === gitData.commit.SHA;
135135

136136
// If a file is present locally, keep only that one in the files array to avoid downloading the other one that never exists
137137
if (!argv.fetchIncludes) {
@@ -236,18 +236,47 @@ export class ParserIncludes {
236236
};
237237
}
238238

239-
static parseIncludeComponent (component: string): {domain: string; port: string; projectPath: string; componentName: string; ref: string} {
239+
static parseIncludeComponent (component: string, gitData: GitData): {domain: string; port: string; projectPath: string; componentName: string; ref: string; isLocalComponent: boolean} {
240240
assert(!component.includes("://"), `This GitLab CI configuration is invalid: component: \`${component}\` should not contain protocol`);
241241
const pattern = /(?<domain>[^/:\s]+)(:(?<port>\d+))?\/(?<projectPath>.+)\/(?<componentName>[^@]+)@(?<ref>.+)/; // https://regexr.com/7v7hm
242242
const gitRemoteMatch = pattern.exec(component);
243243

244244
if (gitRemoteMatch?.groups == null) throw new Error(`This is a bug, please create a github issue if this is something you're expecting to work. input: ${component}`);
245+
246+
const {domain, projectPath, port} = gitRemoteMatch.groups;
247+
let ref = gitRemoteMatch.groups["ref"];
248+
const isLocalComponent = projectPath === `${gitData.remote.group}/${gitData.remote.project}` && ref === gitData.commit.SHA;
249+
250+
if (!isLocalComponent) {
251+
const semanticVersionRangesPattern = /^\d+(\.\d+)?$/;
252+
if (ref == "~latest" || semanticVersionRangesPattern.test(ref)) {
253+
// https://docs.gitlab.com/ci/components/#semantic-version-ranges
254+
let stdout;
255+
try {
256+
stdout = Utils.syncSpawn(["git", "ls-remote", "--tags", `git@${domain}:${projectPath}`]).stdout;
257+
} catch {
258+
stdout = Utils.syncSpawn(["git", "ls-remote", "--tags", `https://${domain}:${port ?? 443}/${projectPath}.git`]).stdout;
259+
}
260+
assert(stdout);
261+
const tags = stdout
262+
.split("\n")
263+
.map((line) => {
264+
return line
265+
.split("\t")[1]
266+
.split("/")[2];
267+
});
268+
const _ref = resolveSemanticVersionRange(ref, tags);
269+
assert(_ref, `This GitLab CI configuration is invalid: component: \`${component}\` - The ref (${ref}) is invalid`);
270+
ref = _ref;
271+
}
272+
}
245273
return {
246-
domain: gitRemoteMatch.groups["domain"],
247-
port: gitRemoteMatch.groups["port"],
248-
projectPath: gitRemoteMatch.groups["projectPath"],
274+
domain: domain,
275+
port: port,
276+
projectPath: projectPath,
249277
componentName: `templates/${gitRemoteMatch.groups["componentName"]}`,
250-
ref: gitRemoteMatch.groups["ref"],
278+
ref: ref,
279+
isLocalComponent: isLocalComponent,
251280
};
252281
}
253282

@@ -298,3 +327,21 @@ export function validateIncludeLocal (filePath: string) {
298327
assert(!filePath.startsWith("./"), `\`${filePath}\` for include:local is invalid. Gitlab does not support relative path (ie. cannot start with \`./\`).`);
299328
assert(!filePath.includes(".."), `\`${filePath}\` for include:local is invalid. Gitlab does not support directory traversal.`);
300329
}
330+
331+
export function resolveSemanticVersionRange (range: string, gitTags: string[]) {
332+
/** sorted list of tags thats compliant to semantic version where index 0 is the latest */
333+
const sanitizedSemverTags = semver.rsort(
334+
gitTags.filter(s => semver.valid(s)),
335+
);
336+
337+
const found = sanitizedSemverTags.find(t => {
338+
if (range == "~latest") {
339+
const semverParsed = semver.parse(t);
340+
assert(semverParsed);
341+
return (semverParsed.prerelease.length == 0 && semverParsed.build.length == 0);
342+
} else {
343+
return semver.satisfies(t, range);
344+
}
345+
});
346+
return found;
347+
}

tests/parser-includes.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {resolveSemanticVersionRange} from "../src/parser-includes.js";
2+
3+
const tests = [
4+
{
5+
name: "`~latest` should return the latest release version",
6+
range: "~latest",
7+
expect: "2.1.0",
8+
},
9+
{
10+
name: "`1` should return the latest minor version",
11+
range: "1",
12+
expect: "1.2.1",
13+
},
14+
{
15+
name: "`1.1` should return the latest patch version",
16+
range: "1.1",
17+
expect: "1.1.1",
18+
},
19+
{
20+
name: "should return undefined if none of the version satisfies the range",
21+
range: "9999999",
22+
expect: undefined,
23+
},
24+
];
25+
26+
const gitTags = [
27+
"1.0.0",
28+
"1.1.0",
29+
"non-semver-compliant-tag",
30+
"2.0.0",
31+
"1.1.1",
32+
"1.2.0",
33+
"1.2.1",
34+
"2.1.0",
35+
"2.0.1",
36+
"2.2.0-rc",
37+
"2.3.0-pre",
38+
];
39+
40+
describe("resolveSemanticVersionRange", () => {
41+
tests.forEach((t) => {
42+
test(t.name, async () => {
43+
const result = resolveSemanticVersionRange(t.range, gitTags);
44+
expect(result).toEqual(t.expect);
45+
});
46+
});
47+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
include:
3+
# https://gitlab.com/explore/catalog/components/go
4+
- component: gitlab.com/components/go/full-pipeline@~latest
5+
6+
stages:
7+
- format
8+
- build
9+
- test
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
include:
3+
- component: gitlab.com/components/go/full-pipeline@0
4+
inputs:
5+
stage_format: format-override
6+
stage_build: build-override
7+
stage_test: test-override
8+
go_version: latest
9+
10+
stages:
11+
- format-override
12+
- build-override
13+
- test-override
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
include:
3+
- component: gitlab.com/components/go/[email protected]
4+
inputs:
5+
stage_format: format-override
6+
stage_build: build-override
7+
stage_test: test-override
8+
go_version: latest
9+
10+
stages:
11+
- format-override
12+
- build-override
13+
- test-override

tests/test-cases/include-component/integration.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,107 @@ test-latest:
6868
expect(writeStreams.stdoutLines[0]).toEqual(expected);
6969
});
7070

71+
test("include-component component (protocol: https) (minor semver)", async () => {
72+
initSpawnSpy([WhenStatics.mockGitRemoteHttp]);
73+
74+
const writeStreams = new WriteStreamsMock();
75+
await handler({
76+
cwd: "tests/test-cases/include-component/component-minor-semver",
77+
preview: true,
78+
}, writeStreams);
79+
80+
81+
const expected = `---
82+
stages:
83+
- .pre
84+
- format-override
85+
- build-override
86+
- test-override
87+
- .post
88+
format-latest:
89+
image:
90+
name: golang:latest
91+
stage: format-override
92+
script:
93+
- go fmt $(go list ./... | grep -v /vendor/)
94+
- go vet $(go list ./... | grep -v /vendor/)
95+
build-latest:
96+
image:
97+
name: golang:latest
98+
stage: build-override
99+
script:
100+
- mkdir -p mybinaries
101+
- go build -o mybinaries ./...
102+
artifacts:
103+
paths:
104+
- mybinaries
105+
test-latest:
106+
image:
107+
name: golang:latest
108+
stage: test-override
109+
script:
110+
- go test -race $(go list ./... | grep -v /vendor/)`;
111+
112+
expect(writeStreams.stdoutLines[0]).toEqual(expected);
113+
});
114+
115+
test("include-component component (protocol: https) (major semver)", async () => {
116+
initSpawnSpy([WhenStatics.mockGitRemoteHttp]);
117+
118+
const writeStreams = new WriteStreamsMock();
119+
await handler({
120+
cwd: "tests/test-cases/include-component/component-minor-semver",
121+
preview: true,
122+
}, writeStreams);
123+
124+
125+
const expected = `---
126+
stages:
127+
- .pre
128+
- format-override
129+
- build-override
130+
- test-override
131+
- .post
132+
format-latest:
133+
image:
134+
name: golang:latest
135+
stage: format-override
136+
script:
137+
- go fmt $(go list ./... | grep -v /vendor/)
138+
- go vet $(go list ./... | grep -v /vendor/)
139+
build-latest:
140+
image:
141+
name: golang:latest
142+
stage: build-override
143+
script:
144+
- mkdir -p mybinaries
145+
- go build -o mybinaries ./...
146+
artifacts:
147+
paths:
148+
- mybinaries
149+
test-latest:
150+
image:
151+
name: golang:latest
152+
stage: test-override
153+
script:
154+
- go test -race $(go list ./... | grep -v /vendor/)`;
155+
156+
expect(writeStreams.stdoutLines[0]).toEqual(expected);
157+
});
158+
159+
test("include-component component (protocol: https) (~latest semver)", async () => {
160+
initSpawnSpy([WhenStatics.mockGitRemoteHttp]);
161+
162+
const writeStreams = new WriteStreamsMock();
163+
await handler({
164+
cwd: "tests/test-cases/include-component/component-latest-semver",
165+
preview: true,
166+
}, writeStreams);
167+
168+
// Should not throw error
169+
// NOTE: potentially this test might be flaky as we're pulling the latest gitlab component
170+
});
171+
71172
test("include-component local component", async () => {
72173
const writeStreams = new WriteStreamsMock();
73174

0 commit comments

Comments
 (0)