Skip to content

Commit 36143d6

Browse files
committed
feat(GoTool): use go.mod to automatically install correct go versions
1 parent 22535e8 commit 36143d6

File tree

6 files changed

+280
-16
lines changed

6 files changed

+280
-16
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,12 +4,20 @@
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",
1014
"loc.input.help.goPath": "A custom value for the GOPATH environment variable.",
1115
"loc.input.label.goBin": "GOBIN",
1216
"loc.input.help.goBin": "A custom value for the GOBIN environment variable.",
1317
"loc.messages.FailedToDownload": "Failed to download Go version %s. Verify that the version is valid and resolve any other issues. Error: %s",
14-
"loc.messages.TempDirNotSet": "The 'Agent.TempDirectory' environment variable was expected to be set."
18+
"loc.messages.TempDirNotSet": "The 'Agent.TempDirectory' environment variable was expected to be set.",
19+
"loc.messages.FailedToFindGoMod": "No go.mod file found in directory '%s'. Make sure go.mod file exists in the specified directory or its subdirectories.",
20+
"loc.messages.GoModVersionDetected": "Detected Go version '%s' from go.mod file: %s",
21+
"loc.messages.GoModVersionNotFound": "Could not find Go version directive in go.mod file: %s",
22+
"loc.messages.FailedToReadGoMod": "Failed to read go.mod file '%s': %s"
1523
}

Tasks/GoToolV0/Tests/L0.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,77 @@
1-
import fs = require('fs');
21
import assert = require('assert');
32
import path = require('path');
3+
import * as ttm from 'azure-pipelines-task-lib/mock-test';
4+
5+
// Declare mocha globals for tsc
6+
/* eslint-disable @typescript-eslint/no-unused-vars */
7+
declare var describe: any; // provided by mocha at runtime
8+
declare var it: any; // provided by mocha
9+
declare var __dirname: string;
10+
interface MochaDone {
11+
(error?: any): any;
12+
}
13+
/* eslint-enable @typescript-eslint/no-unused-vars */
414

515
describe('GoToolV0 Suite', function () {
6-
before(() => {
16+
this.timeout(30000);
17+
18+
function runValidations(validator: () => void, tr: ttm.MockTestRunner) {
19+
try {
20+
validator();
21+
}
22+
catch (error) {
23+
console.log('STDERR', tr.stderr);
24+
console.log('STDOUT', tr.stdout);
25+
throw error;
26+
}
27+
}
28+
29+
it('Installs version from go.mod (single file)', (done: MochaDone) => {
30+
process.env['__case__'] = 'useGoModSingle';
31+
const tp = path.join(__dirname, 'gotoolTests.js');
32+
const tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
33+
tr.run();
34+
runValidations(() => {
35+
assert(tr.succeeded, 'Task should have succeeded');
36+
assert(tr.stdout.indexOf("Parsed Go version '1.22'") > -1, 'Should log parsed Go version 1.22');
37+
}, tr);
38+
done();
739
});
840

9-
after(() => {
41+
it('Installs versions from multiple go.mod files', (done: MochaDone) => {
42+
process.env['__case__'] = 'useGoModMulti';
43+
const tp = path.join(__dirname, 'gotoolTests.js');
44+
const tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
45+
tr.run();
46+
runValidations(() => {
47+
assert(tr.succeeded, 'Task should have succeeded');
48+
assert(tr.stdout.indexOf("Parsed Go version '1.21'") > -1, 'Should parse 1.21');
49+
assert(tr.stdout.indexOf("Parsed Go version '1.22'") > -1, 'Should parse 1.22');
50+
}, tr);
51+
done();
52+
});
53+
54+
it('Fails when go.mod not found', (done: MochaDone) => {
55+
process.env['__case__'] = 'useGoModNotFound';
56+
const tp = path.join(__dirname, 'gotoolTests.js');
57+
const tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
58+
tr.run();
59+
runValidations(() => {
60+
assert(tr.failed, 'Task should have failed');
61+
assert(tr.stdout.indexOf('FailedToFindGoMod') > -1 || tr.stderr.indexOf('FailedToFindGoMod') > -1, 'Should output failure message for missing go.mod');
62+
}, tr);
63+
done();
1064
});
1165

12-
it('Does a basic hello world test', function(done: MochaDone) {
13-
// TODO - add real tests
66+
it('Installs version from explicit input (useGoMod disabled)', (done: MochaDone) => {
67+
process.env['__case__'] = 'explicitVersion';
68+
const tp = path.join(__dirname, 'gotoolTests.js');
69+
const tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
70+
tr.run();
71+
runValidations(() => {
72+
assert(tr.succeeded, 'Task should have succeeded');
73+
assert(tr.stdout.indexOf('Go tool is cached under') > -1, 'Should have cached the tool');
74+
}, tr);
1475
done();
1576
});
1677
});
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: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,82 @@ 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 fs from 'fs';
67
import * as telemetry from 'azure-pipelines-tasks-utility-common/telemetry';
78

89
let osPlat: string = os.platform();
910
let osArch: string = os.arch();
1011

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+
1121
async function run() {
1222
try {
13-
let version = tl.getInput('version', true).trim();
14-
await getGo(version);
15-
telemetry.emitTelemetry('TaskHub', 'GoToolV0', { version });
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+
for (const version of versions) {
32+
await getGo(version);
33+
telemetry.emitTelemetry('TaskHub', 'GoToolV0', { versionSource: 'go.mod', version });
34+
}
35+
} else {
36+
const versionInput = tl.getInput('version', true).trim();
37+
await getGo(versionInput);
38+
telemetry.emitTelemetry('TaskHub', 'GoToolV0', { versionSource: 'input', version: versionInput });
39+
}
1640
}
1741
catch (error) {
18-
tl.setResult(tl.TaskResult.Failed, error);
42+
tl.setResult(tl.TaskResult.Failed, error as any);
43+
}
44+
}
45+
46+
47+
// Recursively find go.mod files starting at workingDirectory and extract the go version directive.
48+
// Returns distinct list of versions (without leading 'v').
49+
function getVersionsFromGoMod(workingDirectory: string): string[] {
50+
const matches = tl.findMatch(workingDirectory, '**/go.mod');
51+
if (!matches || !matches.length) {
52+
return [];
53+
}
54+
55+
const versions: Set<string> = new Set<string>();
56+
for (const filePath of matches) {
57+
tl.debug(`Found go.mod at ${filePath}`);
58+
try {
59+
const fileBuffer = fs.readFileSync(filePath);
60+
if (!fileBuffer.length) {
61+
tl.debug(`go.mod at ${filePath} is empty.`);
62+
continue;
63+
}
64+
const content = fileBuffer.toString();
65+
// Spec: a line starting with 'go ' followed by version (major.minor[.patch])
66+
// We purposely ignore toolchain directive for now.
67+
const regex = /^\s*go\s+([0-9]+\.[0-9]+(?:\.[0-9]+)?)/m;
68+
const match = content.match(regex);
69+
if (match && match[1]) {
70+
let version = match[1].trim();
71+
tl.debug(`Parsed Go version '${version}' from ${filePath}`);
72+
tl.loc('GoModVersionDetected', version, filePath);
73+
versions.add(version);
74+
} else {
75+
tl.warning(tl.loc('GoModVersionNotFound', filePath));
76+
}
77+
} catch (err: any) {
78+
tl.warning(tl.loc('FailedToReadGoMod', filePath, err.message || err));
79+
}
1980
}
81+
return Array.from(versions.values());
2082
}
2183

2284
async function getGo(version: string) {

Tasks/GoToolV0/task.json

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"author": "Microsoft Corporation",
1414
"version": {
1515
"Major": 0,
16-
"Minor": 207,
16+
"Minor": 208,
1717
"Patch": 0
1818
},
1919
"satisfies": [
@@ -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",
@@ -60,6 +78,10 @@
6078
},
6179
"messages": {
6280
"FailedToDownload": "Failed to download Go version %s. Verify that the version is valid and resolve any other issues. Error: %s",
63-
"TempDirNotSet": "The 'Agent.TempDirectory' environment variable was expected to be set."
81+
"TempDirNotSet": "The 'Agent.TempDirectory' environment variable was expected to be set.",
82+
"FailedToFindGoMod": "No go.mod file found in directory '%s'. Make sure go.mod file exists in the specified directory or its subdirectories.",
83+
"GoModVersionDetected": "Detected Go version '%s' from go.mod file: %s",
84+
"GoModVersionNotFound": "Could not find Go version directive in go.mod file: %s",
85+
"FailedToReadGoMod": "Failed to read go.mod file '%s': %s"
6486
}
6587
}

Tasks/GoToolV0/task.loc.json

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"author": "Microsoft Corporation",
1414
"version": {
1515
"Major": 0,
16-
"Minor": 207,
16+
"Minor": 208,
1717
"Patch": 0
1818
},
1919
"satisfies": [
@@ -29,13 +29,31 @@
2929
}
3030
],
3131
"inputs": [
32+
{
33+
"name": "useGoMod",
34+
"type": "boolean",
35+
"label": "ms-resource:loc.input.label.useGoMod",
36+
"defaultValue": false,
37+
"required": false,
38+
"helpMarkDown": "ms-resource:loc.input.help.useGoMod"
39+
},
40+
{
41+
"name": "workingDirectory",
42+
"type": "filePath",
43+
"label": "ms-resource:loc.input.label.workingDirectory",
44+
"defaultValue": "",
45+
"required": false,
46+
"helpMarkDown": "ms-resource:loc.input.help.workingDirectory",
47+
"visibleRule": "useGoMod = true"
48+
},
3249
{
3350
"name": "version",
3451
"type": "string",
3552
"label": "ms-resource:loc.input.label.version",
3653
"defaultValue": "1.10",
3754
"required": true,
38-
"helpMarkDown": "ms-resource:loc.input.help.version"
55+
"helpMarkDown": "ms-resource:loc.input.help.version",
56+
"visibleRule": "useGoMod = false"
3957
},
4058
{
4159
"name": "goPath",
@@ -60,6 +78,10 @@
6078
},
6179
"messages": {
6280
"FailedToDownload": "ms-resource:loc.messages.FailedToDownload",
63-
"TempDirNotSet": "ms-resource:loc.messages.TempDirNotSet"
81+
"TempDirNotSet": "ms-resource:loc.messages.TempDirNotSet",
82+
"FailedToFindGoMod": "ms-resource:loc.messages.FailedToFindGoMod",
83+
"GoModVersionDetected": "ms-resource:loc.messages.GoModVersionDetected",
84+
"GoModVersionNotFound": "ms-resource:loc.messages.GoModVersionNotFound",
85+
"FailedToReadGoMod": "ms-resource:loc.messages.FailedToReadGoMod"
6486
}
6587
}

0 commit comments

Comments
 (0)