Skip to content

Commit dd1fbef

Browse files
committed
feat(GoTool): use go.mod to automatically install correct go versions
Signed-off-by: Christian Artin <[email protected]>
1 parent 3708d18 commit dd1fbef

File tree

6 files changed

+308
-15
lines changed

6 files changed

+308
-15
lines changed

Tasks/GoToolV0/Strings/resources.resjson/en-US/resources.resjson

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
"loc.description": "Find in cache or download a specific version of Go and add it to the PATH",
55
"loc.instanceNameFormat": "Use Go $(version)",
66
"loc.group.displayName.advanced": "Advanced",
7+
"loc.input.label.useGoMod": "Use go.mod",
8+
"loc.input.help.useGoMod": "Select this option to install the Go version matching the specified go.mod file. These files are searched from system.DefaultWorkingDirectory. You can change the search root path by setting working directory input.",
9+
"loc.input.label.workingDirectory": "Working Directory",
10+
"loc.input.help.workingDirectory": "Specify path from where go.mod files should be searched when using `Use go.mod`. If empty, `system.DefaultWorkingDirectory` will be considered as the root path.",
711
"loc.input.label.version": "Version",
812
"loc.input.help.version": "The Go version to download (if necessary) and use. Example: 1.9.3",
913
"loc.input.label.goPath": "GOPATH",
@@ -13,5 +17,9 @@
1317
"loc.input.label.goDownloadUrl": "Go download URL",
1418
"loc.input.help.goDownloadUrl": "URL for downloading Go binaries. Only https://go.dev/dl (official) and https://aka.ms/golang/release/latest (Microsoft build) are supported. If omitted, the official Go download URL (https://go.dev/dl) will be used. This parameter takes priority over the GOTOOL_GODOWNLOADURL environment variable if both are set.",
1519
"loc.messages.FailedToDownload": "Failed to download Go version %s. Verify that the version is valid and resolve any other issues. Error: %s",
16-
"loc.messages.TempDirNotSet": "The 'Agent.TempDirectory' environment variable was expected to be set."
20+
"loc.messages.TempDirNotSet": "The 'Agent.TempDirectory' environment variable was expected to be set.",
21+
"loc.messages.FailedToFindGoMod": "No go.mod file found in directory '%s'. Make sure go.mod file exists in the specified directory or its subdirectories.",
22+
"loc.messages.GoModVersionDetected": "Detected Go version '%s' from go.mod file: %s",
23+
"loc.messages.GoModVersionNotFound": "Could not find Go version directive in go.mod file: %s",
24+
"loc.messages.FailedToReadGoMod": "Failed to read go.mod file '%s': %s"
1725
}

Tasks/GoToolV0/Tests/L0.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as path from "path";
22
import * as assert from "assert";
33
import { MockTestRunner } from "azure-pipelines-task-lib/mock-test";
4-
import tl = require('azure-pipelines-task-lib');
54

65
describe('GoToolV0 Suite', function() {
76
this.timeout(60000);
@@ -10,10 +9,67 @@ describe('GoToolV0 Suite', function() {
109
done();
1110
});
1211

12+
function runValidations(validator: () => void, tr: MockTestRunner) {
13+
try {
14+
validator();
15+
}
16+
catch (error) {
17+
console.log('STDERR', tr.stderr);
18+
console.log('STDOUT', tr.stdout);
19+
throw error;
20+
}
21+
}
22+
1323
after(function () {
1424
// Cleanup if needed
1525
});
1626

27+
// go.mod feature tests
28+
it('Installs version from go.mod (single file)', async () => {
29+
process.env['__case__'] = 'useGoModSingle';
30+
const tp = path.join(__dirname, 'gotoolTests.js');
31+
const tr: MockTestRunner = new MockTestRunner(tp);
32+
await tr.runAsync();
33+
runValidations(() => {
34+
assert(tr.succeeded, 'Task should have succeeded');
35+
assert(tr.stdout.indexOf("Parsed Go version '1.22'") > -1, 'Should log parsed Go version 1.22');
36+
}, tr);
37+
});
38+
39+
it('Installs versions from multiple go.mod files', async () => {
40+
process.env['__case__'] = 'useGoModMulti';
41+
const tp = path.join(__dirname, 'gotoolTests.js');
42+
const tr: MockTestRunner = new MockTestRunner(tp);
43+
await tr.runAsync();
44+
runValidations(() => {
45+
assert(tr.succeeded, 'Task should have succeeded');
46+
assert(tr.stdout.indexOf("Parsed Go version '1.21'") > -1, 'Should parse 1.21');
47+
assert(tr.stdout.indexOf("Parsed Go version '1.22'") > -1, 'Should parse 1.22');
48+
}, tr);
49+
});
50+
51+
it('Fails when go.mod not found', async () => {
52+
process.env['__case__'] = 'useGoModNotFound';
53+
const tp = path.join(__dirname, 'gotoolTests.js');
54+
const tr: MockTestRunner = new MockTestRunner(tp);
55+
await tr.runAsync();
56+
runValidations(() => {
57+
assert(tr.failed, 'Task should have failed');
58+
assert(tr.stdout.indexOf('FailedToFindGoMod') > -1 || tr.stderr.indexOf('FailedToFindGoMod') > -1, 'Should output failure message for missing go.mod');
59+
}, tr);
60+
});
61+
62+
it('Installs version from explicit input (useGoMod disabled)', async () => {
63+
process.env['__case__'] = 'explicitVersion';
64+
const tp = path.join(__dirname, 'gotoolTests.js');
65+
const tr: MockTestRunner = new MockTestRunner(tp);
66+
await tr.runAsync();
67+
runValidations(() => {
68+
assert(tr.succeeded, 'Task should have succeeded');
69+
assert(tr.stdout.indexOf('Go tool is cached under') > -1, 'Should have cached the tool');
70+
}, tr);
71+
});
72+
1773
// Official Go (go.dev) tests
1874
it('Should install official Go with full patch version', async () => {
1975
let tp = path.join(__dirname, 'L0OfficialGoPatch.js');
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import ma = require('azure-pipelines-task-lib/mock-answer');
2+
import tmrm = require('azure-pipelines-task-lib/mock-run');
3+
import path = require('path');
4+
5+
const taskPath = path.join(__dirname, '..', 'gotool.js');
6+
const tmr: tmrm.TaskMockRunner = new tmrm.TaskMockRunner(taskPath);
7+
8+
const testCase = process.env['__case__'];
9+
10+
// Configure inputs depending on scenario
11+
switch (testCase) {
12+
case 'useGoModSingle':
13+
tmr.setInput('useGoMod', 'true');
14+
tmr.setInput('workingDirectory', 'work');
15+
break;
16+
case 'useGoModMulti':
17+
tmr.setInput('useGoMod', 'true');
18+
tmr.setInput('workingDirectory', 'repo');
19+
break;
20+
case 'useGoModNotFound':
21+
tmr.setInput('useGoMod', 'true');
22+
tmr.setInput('workingDirectory', 'empty');
23+
break;
24+
case 'explicitVersion':
25+
tmr.setInput('useGoMod', 'false');
26+
tmr.setInput('version', '1.21.0');
27+
break;
28+
default:
29+
throw new Error('Unknown __case__ value: ' + testCase);
30+
}
31+
32+
const answers: ma.TaskLibAnswers = <ma.TaskLibAnswers>{
33+
'assertAgent': { '2.115.0': true }
34+
};
35+
tmr.setAnswers(answers);
36+
37+
// Dynamic findMatch behavior based on test case
38+
const tl = require('azure-pipelines-task-lib/mock-task');
39+
const tlClone = Object.assign({}, tl);
40+
tlClone.findMatch = function(root: string, pattern: string) {
41+
if (pattern !== '**/go.mod') return [];
42+
if (testCase === 'useGoModSingle' && root === 'work') {
43+
return ['work/go.mod'];
44+
}
45+
if (testCase === 'useGoModMulti' && root === 'repo') {
46+
return ['repo/a/go.mod', 'repo/b/go.mod'];
47+
}
48+
if (testCase === 'useGoModNotFound') {
49+
return [];
50+
}
51+
return [];
52+
};
53+
tlClone.getVariable = function(v: string) {
54+
if (v.toLowerCase() === 'system.defaultworkingdirectory') {
55+
if (testCase === 'useGoModSingle') return 'work';
56+
if (testCase === 'useGoModMulti') return 'repo';
57+
if (testCase === 'useGoModNotFound') return 'empty';
58+
}
59+
if (v.toLowerCase() === 'agent.tempdirectory') return 'temp';
60+
return null;
61+
};
62+
tlClone.assertAgent = function() { return; };
63+
tlClone.loc = function(locString: string, ...params: any[]) { return locString + ' ' + params.join(' '); };
64+
tlClone.prependPath = function(p: string) { /* no-op for tests */ };
65+
tmr.registerMock('azure-pipelines-task-lib/mock-task', tlClone);
66+
67+
// fs mock
68+
tmr.registerMock('fs', {
69+
readFileSync: function(p: string) {
70+
if (p === 'work/go.mod') return Buffer.from('module example.com/single\n\n go 1.22');
71+
if (p === 'repo/a/go.mod') return Buffer.from('module example.com/a\n go 1.21');
72+
if (p === 'repo/b/go.mod') return Buffer.from('module example.com/b\n go 1.22');
73+
return Buffer.from('');
74+
}
75+
});
76+
77+
// tool-lib mock
78+
tmr.registerMock('azure-pipelines-tool-lib/tool', {
79+
findLocalTool: function(tool: string, version: string) { return false; },
80+
downloadTool: function(url: string) { return 'download'; },
81+
extractTar: function(downloadPath: string) { return 'ext'; },
82+
extractZip: function(downloadPath: string) { return 'ext'; },
83+
cacheDir: function(dir: string, tool: string, version: string) { return path.join('cache', tool, version); },
84+
prependPath: function(p: string) { /* no-op */ }
85+
});
86+
87+
tmr.registerMock('azure-pipelines-tasks-utility-common/telemetry', { emitTelemetry: function() {} });
88+
89+
tmr.run();

Tasks/GoToolV0/gotool.ts

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,50 @@ import * as tl from 'azure-pipelines-task-lib/task';
33
import * as os from 'os';
44
import * as path from 'path';
55
import * as util from 'util';
6-
import * as telemetry from 'azure-pipelines-tasks-utility-common/telemetry';
76
import * as fs from 'fs';
7+
import * as telemetry from 'azure-pipelines-tasks-utility-common/telemetry';
88

99
const osPlat = os.platform();
1010
const osArch = os.arch();
1111

12+
// Set resource path for localization
13+
try {
14+
const taskManifestPath = path.join(__dirname, 'task.json');
15+
tl.debug('Setting resource path to ' + taskManifestPath);
16+
tl.setResourcePath(taskManifestPath);
17+
} catch (e) {
18+
tl.debug('Failed to set resource path: ' + (e as any).message);
19+
}
20+
1221
async function run() {
1322
try {
14-
const rawVersion = tl.getInput('version', true);
15-
if (!rawVersion || ['null','undefined',''].includes(rawVersion.trim().toLowerCase())) {
16-
throw new Error("Input 'version' is required and must not be empty, 'null' or 'undefined'.");
23+
const useGoMod: boolean = tl.getBoolInput('useGoMod', false);
24+
const workingDirectory: string = tl.getPathInput('workingDirectory', false) || tl.getVariable('System.DefaultWorkingDirectory') || process.cwd();
25+
26+
if (useGoMod) {
27+
const versions = getVersionsFromGoMod(workingDirectory);
28+
if (!versions.length) {
29+
throw tl.loc('FailedToFindGoMod', workingDirectory);
30+
}
31+
const downloadUrl = resolveDownloadUrl();
32+
for (const version of versions) {
33+
const resolvedVersion = await getGo(version, downloadUrl);
34+
telemetry.emitTelemetry('TaskHub', 'GoToolV0', { versionSource: 'go.mod', version: resolvedVersion, customBaseUrl: String(!!downloadUrl) });
35+
}
36+
} else {
37+
const rawVersion = tl.getInput('version', true);
38+
if (!rawVersion || ['null','undefined',''].includes(rawVersion.trim().toLowerCase())) {
39+
throw new Error("Input 'version' is required and must not be empty, 'null' or 'undefined'.");
40+
}
41+
const version = rawVersion.trim();
42+
43+
const downloadUrl = resolveDownloadUrl();
44+
const resolvedVersion = await getGo(version, downloadUrl);
45+
telemetry.emitTelemetry('TaskHub', 'GoToolV0', { versionSource: 'input', version: resolvedVersion, customBaseUrl: String(!!downloadUrl) });
1746
}
18-
const version = rawVersion.trim();
19-
20-
const downloadUrl = resolveDownloadUrl();
21-
const resolvedVersion = await getGo(version, downloadUrl);
22-
telemetry.emitTelemetry('TaskHub', 'GoToolV0', { version: resolvedVersion, customBaseUrl: String(!!downloadUrl) });
2347
}
2448
catch (error) {
25-
tl.setResult(tl.TaskResult.Failed, error);
49+
tl.setResult(tl.TaskResult.Failed, error as any);
2650
}
2751
}
2852

@@ -51,6 +75,82 @@ function resolveDownloadUrl(): string | undefined {
5175
return downloadUrl;
5276
}
5377

78+
// Recursively find go.mod files starting at workingDirectory and extract the go version directive.
79+
// Returns distinct list of versions (without leading 'v').
80+
function getVersionsFromGoMod(workingDirectory: string): string[] {
81+
const matches = tl.findMatch(workingDirectory, '**/go.mod');
82+
if (!matches || !matches.length) {
83+
return [];
84+
}
85+
86+
const versions: Set<string> = new Set<string>();
87+
for (const filePath of matches) {
88+
tl.debug(`Found go.mod at ${filePath}`);
89+
try {
90+
const fileBuffer = fs.readFileSync(filePath);
91+
if (!fileBuffer.length) {
92+
tl.debug(`go.mod at ${filePath} is empty.`);
93+
continue;
94+
}
95+
const content = fileBuffer.toString();
96+
// Spec: a line starting with 'go ' followed by version (major.minor[.patch])
97+
// We purposely ignore toolchain directive for now.
98+
const regex = /^\s*go\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)/m;
99+
const match = content.match(regex);
100+
if (match && match[1]) {
101+
let version = match[1].trim();
102+
tl.debug(`Parsed Go version '${version}' from ${filePath}`);
103+
tl.loc('GoModVersionDetected', version, filePath);
104+
versions.add(version);
105+
} else {
106+
tl.warning(tl.loc('GoModVersionNotFound', filePath));
107+
}
108+
} catch (err: any) {
109+
tl.warning(tl.loc('FailedToReadGoMod', filePath, err.message || err));
110+
}
111+
}
112+
return Array.from(versions.values());
113+
}
114+
115+
async function getGo(version: string, baseUrl?: string): Promise<string> {
116+
const resolved = await resolveVersionAndCache(version, baseUrl);
117+
tl.debug(`resolveVersionAndCache result filenameVersion=${resolved.filenameVersion} cacheVersion=${resolved.cacheVersion ?? '<?>'} toolName=${resolved.toolName} (type=${typeof resolved.cacheVersion})`);
118+
let toolPath: string | null = null;
119+
120+
if (resolved.cacheVersion) {
121+
toolPath = toolLib.findLocalTool(resolved.toolName, resolved.cacheVersion);
122+
}
123+
}
124+
125+
const versions: Set<string> = new Set<string>();
126+
for (const filePath of matches) {
127+
tl.debug(`Found go.mod at ${filePath}`);
128+
try {
129+
const fileBuffer = fs.readFileSync(filePath);
130+
if (!fileBuffer.length) {
131+
tl.debug(`go.mod at ${filePath} is empty.`);
132+
continue;
133+
}
134+
const content = fileBuffer.toString();
135+
// Spec: a line starting with 'go ' followed by version (major.minor[.patch])
136+
// We purposely ignore toolchain directive for now.
137+
const regex = /^\s*go\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)/m;
138+
const match = content.match(regex);
139+
if (match && match[1]) {
140+
let version = match[1].trim();
141+
tl.debug(`Parsed Go version '${version}' from ${filePath}`);
142+
tl.loc('GoModVersionDetected', version, filePath);
143+
versions.add(version);
144+
} else {
145+
tl.warning(tl.loc('GoModVersionNotFound', filePath));
146+
}
147+
} catch (err: any) {
148+
tl.warning(tl.loc('FailedToReadGoMod', filePath, err.message || err));
149+
}
150+
}
151+
return Array.from(versions.values());
152+
}
153+
54154
async function getGo(version: string, baseUrl?: string): Promise<string> {
55155
const resolved = await resolveVersionAndCache(version, baseUrl);
56156
tl.debug(`resolveVersionAndCache result filenameVersion=${resolved.filenameVersion} cacheVersion=${resolved.cacheVersion ?? '<?>'} toolName=${resolved.toolName} (type=${typeof resolved.cacheVersion})`);

Tasks/GoToolV0/task.json

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,31 @@
2929
}
3030
],
3131
"inputs": [
32+
{
33+
"name": "useGoMod",
34+
"type": "boolean",
35+
"label": "Use go.mod",
36+
"defaultValue": false,
37+
"required": false,
38+
"helpMarkDown": "Select this option to install the Go version matching the specified go.mod file. These files are searched from system.DefaultWorkingDirectory. You can change the search root path by setting working directory input."
39+
},
40+
{
41+
"name": "workingDirectory",
42+
"type": "filePath",
43+
"label": "Working Directory",
44+
"defaultValue": "",
45+
"required": false,
46+
"helpMarkDown": "Specify path from where go.mod files should be searched when using `Use go.mod`. If empty, `system.DefaultWorkingDirectory` will be considered as the root path.",
47+
"visibleRule": "useGoMod = true"
48+
},
3249
{
3350
"name": "version",
3451
"type": "string",
3552
"label": "Version",
3653
"defaultValue": "1.10",
3754
"required": true,
38-
"helpMarkDown": "The Go version to download (if necessary) and use. Example: 1.9.3"
55+
"helpMarkDown": "The Go version to download (if necessary) and use. Example: 1.9.3",
56+
"visibleRule": "useGoMod = false"
3957
},
4058
{
4159
"name": "goPath",
@@ -77,6 +95,10 @@
7795
},
7896
"messages": {
7997
"FailedToDownload": "Failed to download Go version %s. Verify that the version is valid and resolve any other issues. Error: %s",
80-
"TempDirNotSet": "The 'Agent.TempDirectory' environment variable was expected to be set."
98+
"TempDirNotSet": "The 'Agent.TempDirectory' environment variable was expected to be set.",
99+
"FailedToFindGoMod": "No go.mod file found in directory '%s'. Make sure go.mod file exists in the specified directory or its subdirectories.",
100+
"GoModVersionDetected": "Detected Go version '%s' from go.mod file: %s",
101+
"GoModVersionNotFound": "Could not find Go version directive in go.mod file: %s",
102+
"FailedToReadGoMod": "Failed to read go.mod file '%s': %s"
81103
}
82104
}

0 commit comments

Comments
 (0)