Skip to content
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

Read node versions from Github workflows #68

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
Expand Up @@ -3,6 +3,7 @@
const Fs = require('fs');
const Path = require('path');

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


Expand All @@ -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
Expand Up @@ -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');
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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) {

Expand Down
15 changes: 9 additions & 6 deletions lib/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 = {};
Expand All @@ -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 };
};
Loading