Skip to content

Read node versions from Github workflows #68

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
223 changes: 223 additions & 0 deletions lib/github-actions/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
'use strict';

const _ = require('lodash');
const Nv = require('@pkgjs/nv');
const Semver = require('semver');


const internals = {};


internals.parseActionsSetupNode = function * (workflow, file) {

for (const job of Object.values(workflow.jobs)) {

if (!job.steps) {
continue;
}

const nodeSteps = job.steps.filter(({ uses }) => uses && uses.startsWith('actions/setup-node'));
for (const step of nodeSteps) {
const nodeVersion = step.with && step.with['node-version'];

if (!nodeVersion) {
// Docs say: "The node-version input is optional. If not supplied, the node version that is PATH will be used."
// Therefore we cannot reliably detect a specific version, but we do want to let the user know
yield 'not-set';
continue;
}

const matrixMatch = nodeVersion.match(/^\${{\s+matrix.(?<matrixVarName>.*)\s+}}$/);
if (matrixMatch) {
const matrix = job.strategy.matrix[matrixMatch.groups.matrixVarName];

yield * matrix;
continue;
}

const envMatch = nodeVersion.match(/^\${{\s+env.(?<envVarName>.*)\s+}}$/);
if (envMatch) {
const env = {
...workflow.env,
...step.env
};
const envValue = env[envMatch.groups.envVarName];

if (!envValue) {
yield 'not-set';
continue;
}

yield envValue;
continue;
}

yield nodeVersion;
}
}
};


internals.resolveLjharbPreset = function * ({ preset }) {

// @todo: with has more options - resolve to precise versions here and yield the full list
// @todo: return preset as well as resolved version

if (preset === '0.x') {
yield * ['0.8', '0.10', '0.12'];
return;
}

if (preset === 'iojs') {
yield * ['1', '2', '3'];
return;
}

if (!Semver.validRange(preset)) {
yield preset;
return;
}

yield * internals.latestNodeVersions.filter(({ resolved }) => Semver.satisfies(resolved, preset)).map(({ major }) => major);
};


internals.parseLjharbActions = function * (workflow, file) {

for (const job of Object.values(workflow.jobs)) {

if (!job.steps) {
continue;
}

const nodeSteps = job.steps.filter(({ uses }) => {

if (!uses) {
return false;
}

return uses.startsWith('ljharb/actions/node/run') || uses.startsWith('ljharb/actions/node/install');
});

for (const step of nodeSteps) {
const nodeVersion = step.with && step.with['node-version'];

if (!nodeVersion) {
yield 'lts/*'; // @todo: find ref which tells us that this is so
continue;
}

const matrixMatch = nodeVersion.match(/^\${{\s+matrix.(?<matrixVarName>.*)\s+}}$/);
if (matrixMatch) {

let needs = job.strategy.matrix;
if (typeof job.strategy.matrix !== 'string') {

const matrix = job.strategy.matrix[matrixMatch.groups.matrixVarName];

if (!matrix) {
throw new Error(`Unable to find matrix variable '${matrixMatch.groups.matrixVarName}' in the matrix in ${file}`);
}

if (typeof matrix !== 'string') {
// @todo find an example
yield * matrix;
continue;
}

// example: eslint-plugin-react
needs = matrix;
}

const fromJsonMatch = needs.match(/^\${{\s+fromJson\(needs\.(?<needJobName>.*)\.outputs\.(?<needOutputName>.*)\)\s+}}$/);
if (fromJsonMatch) {
const { needJobName, needOutputName } = fromJsonMatch.groups;
const needJob = workflow.jobs[needJobName];
const needOutput = needJob.outputs[needOutputName];
const stepMatch = needOutput.match(/^\${{\s+steps\.(?<needStepName>.*)\.outputs\.(?<needStepOutputName>.*)\s+}}$/);

if (!stepMatch) {
throw new Error(`Unable to parse need output: ${needOutput} in ${file}`);
}

const { needStepName/*, needStepOutputName*/ } = stepMatch.groups;
const needStep = needJob.steps.find(({ id }) => id === needStepName);

if (!needStep || !needStep.uses.startsWith('ljharb/actions/node/matrix')) {
throw new Error(`Unrecognized action in ${needOutput} in ${file}`);
}

yield * internals.resolveLjharbPreset(needStep.with);
continue;
}

throw new Error(`Unable to parse the job matrix: ${job.strategy.matrix} in ${file}`);
}

yield nodeVersion;
}
}
};


exports.detect = async (meta) => {

if (!internals.latestNodeVersions) {
// @todo: unhardcode
const latest = [];
for (const v of ['4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16']) {
const resolved = await Nv(v);
latest.push({ major: v, resolved: resolved[resolved.length - 1].version });
}

internals.latestNodeVersions = latest;
}

const files = await meta.loadFolder('.github/workflows');
const rawSet = new Set();
const byFileSets = {};

if (!files.length) {
// explicitly return no `githubActions` - this is different to finding actions and detecting no Node.js versions
return;
}

for (const file of files) {

if (!file.endsWith('.yaml') && !file.endsWith('.yml')) {
continue;
}

const workflow = await meta.loadFile(`.github/workflows/${file}`, { yaml: true });
byFileSets[file] = byFileSets[file] || new Set();

for (const version of internals.parseActionsSetupNode(workflow, file)) {
rawSet.add(version);
byFileSets[file].add(version);
}

for (const version of internals.parseLjharbActions(workflow, file)) {
rawSet.add(version);
byFileSets[file].add(version);
}
}

const raw = [...rawSet];
const byFile = _.mapValues(byFileSets, (set) => [...set]);

const resolved = {};

for (const version of raw) {

const nv = await Nv(version);

if (!nv.length) {
resolved[version] = false;
}
else {
resolved[version] = nv[nv.length - 1].version;
}
}

return { githubActions: { byFile, raw, resolved } };
};
19 changes: 19 additions & 0 deletions lib/loader/contents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

const Yaml = require('js-yaml');

exports.convert = (buffer, options) => {

if (options.json) {
return JSON.parse(buffer.toString());
}

if (options.yaml) {
return Yaml.load(buffer, {
schema: Yaml.FAILSAFE_SCHEMA,
json: true
});
}

return buffer;
};
13 changes: 8 additions & 5 deletions lib/loader/path.js
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
const Fs = require('fs');
const Path = require('path');

const Contents = require('./contents');
const Utils = require('../utils');


@@ -24,17 +25,19 @@ exports.create = async (path) => {

return simpleGit.revparse(['HEAD']);
},
loadFolder: (folderPath) => {

const fullPath = Path.join(path, folderPath);

return Fs.existsSync(fullPath) ? Fs.readdirSync(fullPath) : [];
},
loadFile: (filename, options = {}) => {

const fullPath = Path.join(path, filename);

const buffer = Fs.readFileSync(fullPath);

if (options.json) {
return JSON.parse(buffer.toString());
}

return buffer;
return Contents.convert(buffer, options);
}
};
};
51 changes: 45 additions & 6 deletions lib/loader/repository.js
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@

const GitUrlParse = require('git-url-parse');

const Contents = require('./contents');
const Logger = require('../logger');
const OctokitWrapper = require('./octokit-wrapper');
const Utils = require('../utils');
@@ -30,6 +31,48 @@ exports.create = (repository) => {

return head;
},
loadFolder: async (path) => {

if (parsedRepository.source !== 'github.com') {
throw new Error('Only github.com paths supported, feel free to PR at https://github.com/pkgjs/detect-node-support');
}

const resource = `${parsedRepository.full_name}:${path}@HEAD`;
Logger.log(['loader'], 'Loading: %s', resource);

const octokit = OctokitWrapper.create();

try {

let result;
if (internals.cache.has(resource)) {
Logger.log(['loader'], 'From cache: %s', resource);
result = internals.cache.get(resource);
}
else {
result = await octokit.repos.getContent({
owner: parsedRepository.owner,
repo: parsedRepository.name,
path
});
}

internals.cache.set(resource, result);

Logger.log(['loader'], 'Loaded: %s', resource);

return result.data.map(({ name }) => name);
}
catch (err) {

if (err.status === 404) {
return []; // @todo: is this right?
}

Logger.error(['loader'], 'Failed to load: %s', resource);
throw err;
}
},
loadFile: async (filename, options = {}) => {

if (parsedRepository.source !== 'github.com') {
@@ -60,13 +103,9 @@ exports.create = (repository) => {

Logger.log(['loader'], 'Loaded: %s', resource);

const content = Buffer.from(result.data.content, 'base64');

if (options.json) {
return JSON.parse(content.toString());
}
const buffer = Buffer.from(result.data.content, 'base64');

return content;
return Contents.convert(buffer, options);
}
catch (err) {

15 changes: 9 additions & 6 deletions lib/package.js
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@
const Fs = require('fs');
const { URL } = require('url');

const GithubActions = require('./github-actions');
const Engines = require('./engines');
const Loader = require('./loader');
const Travis = require('./travis');
@@ -43,14 +44,15 @@ exports.detect = async (what) => {

const { path, repository, packageName } = internals.what(what);

const { loadFile, getCommit } = await Loader.create({ path, repository, packageName });
const { loadFile, loadFolder, getCommit } = await Loader.create({ path, repository, packageName });

const packageJson = await loadFile('package.json', { json: true });

const meta = {
packageJson,
getCommit,
loadFile
loadFile,
loadFolder
};

const result = {};
@@ -60,10 +62,11 @@ exports.detect = async (what) => {
result.commit = await meta.getCommit();
result.timestamp = Date.now();

const travis = await Travis.detect(meta);
const engines = await Engines.detect(meta);

Object.assign(result, travis, engines);
Object.assign(result, ...await Promise.all([
GithubActions.detect(meta),
Travis.detect(meta),
Engines.detect(meta)
]));

return { result, meta };
};
8 changes: 1 addition & 7 deletions lib/travis/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use strict';

const Nv = require('@pkgjs/nv');
const Yaml = require('js-yaml');

const TravisImports = require('./imports');
const Utils = require('../utils');
@@ -75,7 +74,7 @@ internals.scan = async (travisYaml, options) => {
exports.detect = async ({ loadFile }) => {

try {
var buffer = await loadFile('.travis.yml');
var travisYaml = await loadFile('.travis.yml', { yaml: true });
}
catch (err) {

@@ -86,11 +85,6 @@ exports.detect = async ({ loadFile }) => {
throw err;
}

const travisYaml = Yaml.load(buffer, {
schema: Yaml.FAILSAFE_SCHEMA,
json: true
});

return {
travis: await internals.scan(travisYaml, { loadFile })
};
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -42,8 +42,10 @@
"debug": "^4.1.1",
"git-url-parse": "^11.1.2",
"js-yaml": "^4.0.0",
"lodash": "^4.17.21",
"minimist": "^1.2.5",
"pacote": "^12.0.0",
"semver": "^7.3.5",
"simple-git": "^3.0.0",
"tmp": "^0.2.0"
}
1,129 changes: 619 additions & 510 deletions test/fixtures/node-release-dist.json

Large diffs are not rendered by default.

36 changes: 32 additions & 4 deletions test/fixtures/node-release-schedule.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"v0.8": {
"start": "2012-06-25",
"end": "2014-07-31"
},
"v0.10": {
"start": "2013-03-11",
"end": "2016-10-31"
@@ -46,7 +50,7 @@
"v10": {
"start": "2018-04-24",
"lts": "2018-10-30",
"maintenance": "2020-04-01",
"maintenance": "2020-05-19",
"end": "2021-04-30",
"codename": "Dubnium"
},
@@ -58,7 +62,7 @@
"v12": {
"start": "2019-04-23",
"lts": "2019-10-21",
"maintenance": "2020-10-21",
"maintenance": "2020-11-30",
"end": "2022-04-30",
"codename": "Erbium"
},
@@ -69,9 +73,33 @@
},
"v14": {
"start": "2020-04-21",
"lts": "2020-10-20",
"maintenance": "2021-10-20",
"lts": "2020-10-27",
"maintenance": "2021-10-19",
"end": "2023-04-30",
"codename": "Fermium"
},
"v15": {
"start": "2020-10-20",
"maintenance": "2021-04-01",
"end": "2021-06-01"
},
"v16": {
"start": "2021-04-20",
"lts": "2021-10-26",
"maintenance": "2022-10-18",
"end": "2024-04-30",
"codename": "Gallium"
},
"v17": {
"start": "2021-10-19",
"maintenance": "2022-04-01",
"end": "2022-06-01"
},
"v18": {
"start": "2022-04-19",
"lts": "2022-10-25",
"maintenance": "2023-10-18",
"end": "2025-04-30",
"codename": ""
}
}
157 changes: 101 additions & 56 deletions test/index.js

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions test/travis.js
Original file line number Diff line number Diff line change
@@ -58,7 +58,7 @@ describe('.travis.yml parsing', () => {
timestamp: 1580673602000,
travis: {
raw: ['14'],
resolved: { '14': '14.3.0' }
resolved: { '14': '14.18.3' }
}
});
});
@@ -81,7 +81,7 @@ describe('.travis.yml parsing', () => {
timestamp: 1580673602000,
travis: {
raw: ['14'],
resolved: { '14': '14.3.0' }
resolved: { '14': '14.18.3' }
}
});
});
@@ -103,7 +103,7 @@ describe('.travis.yml parsing', () => {
timestamp: 1580673602000,
travis: {
raw: ['14'],
resolved: { '14': '14.3.0' }
resolved: { '14': '14.18.3' }
}
});
});
@@ -157,7 +157,7 @@ describe('.travis.yml parsing', () => {
timestamp: 1580673602000,
travis: {
raw: ['14'],
resolved: { '14': '14.3.0' }
resolved: { '14': '14.18.3' }
}
});

@@ -188,8 +188,8 @@ describe('.travis.yml parsing', () => {
'8': '8.17.0',
'10.15': '10.15.3',
'10.16': '10.16.3',
'12': '12.17.0',
'14': '14.3.0'
'12': '12.22.9',
'14': '14.18.3'
}
}
});
@@ -213,7 +213,7 @@ describe('.travis.yml parsing', () => {
travis: {
raw: ['12'],
resolved: {
'12': '12.17.0'
'12': '12.22.9'
}
}
});
@@ -237,7 +237,7 @@ describe('.travis.yml parsing', () => {
travis: {
raw: ['12'],
resolved: {
'12': '12.17.0'
'12': '12.22.9'
}
}
});