Skip to content

Commit a897519

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

File tree

6 files changed

+258
-12
lines changed

6 files changed

+258
-12
lines changed

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

Lines changed: 4 additions & 0 deletions
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",

Tasks/GoToolV0/Tests/L0.ts

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,71 @@
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+
/* eslint-enable @typescript-eslint/no-unused-vars */
411

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

9-
after(() => {
37+
it('Installs versions from multiple go.mod files', async () => {
38+
process.env['__case__'] = 'useGoModMulti';
39+
const tp = path.join(__dirname, 'gotoolTests.js');
40+
const tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
41+
await tr.runAsync();
42+
runValidations(() => {
43+
assert(tr.succeeded, 'Task should have succeeded');
44+
assert(tr.stdout.indexOf("Parsed Go version '1.21'") > -1, 'Should parse 1.21');
45+
assert(tr.stdout.indexOf("Parsed Go version '1.22'") > -1, 'Should parse 1.22');
46+
}, tr);
1047
});
1148

12-
it('Does a basic hello world test', function(done: MochaDone) {
13-
// TODO - add real tests
14-
done();
49+
it('Fails when go.mod not found', async () => {
50+
process.env['__case__'] = 'useGoModNotFound';
51+
const tp = path.join(__dirname, 'gotoolTests.js');
52+
const tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
53+
await tr.runAsync();
54+
runValidations(() => {
55+
assert(tr.failed, 'Task should have failed');
56+
assert(tr.stdout.indexOf('FailedToFindGoMod') > -1 || tr.stderr.indexOf('FailedToFindGoMod') > -1, 'Should output failure message for missing go.mod');
57+
}, tr);
58+
});
59+
60+
it('Installs version from explicit input (useGoMod disabled)', async () => {
61+
process.env['__case__'] = 'explicitVersion';
62+
const tp = path.join(__dirname, 'gotoolTests.js');
63+
const tr: ttm.MockTestRunner = new ttm.MockTestRunner(tp);
64+
await tr.runAsync();
65+
runValidations(() => {
66+
assert(tr.succeeded, 'Task should have succeeded');
67+
assert(tr.stdout.indexOf('Go tool is cached under') > -1, 'Should have cached the tool');
68+
}, tr);
1569
});
1670
});
71+
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: 19 additions & 1 deletion
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",

Tasks/GoToolV0/task.loc.json

Lines changed: 19 additions & 1 deletion
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": "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",

0 commit comments

Comments
 (0)