-
Notifications
You must be signed in to change notification settings - Fork 21
/
lts.js
233 lines (197 loc) · 9.05 KB
/
lts.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
const fs = require("fs/promises");
const path = require("path");
const getCurrentLTSVersion = async () => {
const LTS_SCHEDULE_URL =
"https://raw.githubusercontent.com/nodejs/Release/main/schedule.json";
// The collection of Node versions is given to us as a map. The keys of the
// map are the major Node.js versions, and the objects are some metadata about
// when the version was first released, when it will become LTS (if ever),
// when it will enter maintenance mode, and when it will exit maintenance
// mode. All we really need to know is the current LTS major version, so we
// need to do some work on this collection.
const nodeVersionMap = await fetch(LTS_SCHEDULE_URL).then((r) => r.json());
const pastAndPresentLTSVersions = Object.entries(nodeVersionMap)
// First get rid of Node.js versions that are not or will never be LTS, as
// well as versions that will be LTS in the future. If they aren't LTS now,
// we don't want the project to use them.
//
// new Date(undefined) returns an InvalidDate object whose .getTime() method
// returns NaN. As a result, any comparison will be false, so this will also
// filter out any versions that do not have an "lts" property.
.filter(([, { lts }]) => new Date(lts).getTime() < Date.now())
// Next map each key/value pair into just the numeric version number because
// that's all we need after filtering down to only current and past LTSes.
// The versions are formatted as "vXX" where "XX" is the actual number, so
// we also need to slice off the "v" at the start.
.map(([version]) => Number.parseInt(version.slice(1), 10));
// Return the largest major version that is a past-or-present LTS. That is the
// current LTS.
return Math.max(...pastAndPresentLTSVersions);
};
const getPackageNodeVersion = async () => {
const pkg = JSON.parse(await fs.readFile("./package.json"));
if (pkg.engines?.node) {
// The engine property should be in semver format, so the major version will
// be before the first dot.
const [engineMajor] = pkg.engines.node.split(".");
// The semver format may also include modifiers like ^ or ~ at the beginning,
// so strip out anything from the major version that isn't a number and then
// parse it into a number.
return Number.parseInt(engineMajor.replace(/\D/g, ""), 10);
}
return "not set";
};
const getDockerNodeVersion = async () => {
const dockerfile = await fs.readFile("Dockerfile", { encoding: "utf-8" });
// The base container is specified on the first line of the Dockerfile that is
// not a comment.
const baseContainer = dockerfile
.split("\n")
.find((line) => !line.trim().startsWith("#"));
// If the base container is Node, then we can pull out the version from it.
if (baseContainer.startsWith("FROM node:")) {
return Number.parseInt(baseContainer.replace("FROM node:", ""), 10);
}
// Otherwise, it's not a Node container at all. For Charlie, that is certainly
// incorrect, so return a Node version that will cause the test to fail.
return "not set";
};
const getWorkflowLinesWithInvalidNodeVersion = async (currentLTSVersion) => {
// First, get a list of all the YAML files in the workflows directory
const workflowPath = ".github/workflows";
const ymlPaths = (await fs.readdir(workflowPath))
.filter((f) => f.endsWith(".yml"))
// And smoosh the filenames together with the path
.map((f) => path.join(workflowPath, f));
// Capture which workflow files have invalid Node references
const workflowFileWithInvalidNodeReferences = [];
for await (const ymlFilePath of ymlPaths) {
// Read the workflow and then split it into lines, so we can report which
// line numbers are problematic.
const workflow = await fs.readFile(ymlFilePath, { encoding: "utf-8" });
const lines = workflow.split("\n");
// Capture which lines incorporate an invalid Node version
const linesWithInvalidNodeReferences = [];
for (const [index, line] of lines.entries()) {
// Node could be referenced via a workflow job or step container.
const [, nodeContainerVersion] = line.match(
/^\s*container: node:(\d\S+)/,
) ?? [null, false];
if (nodeContainerVersion) {
// We only care about invalid Node versions.
const version = Number.parseInt(nodeContainerVersion, 10);
if (version !== currentLTSVersion) {
linesWithInvalidNodeReferences.push([index + 1, version]);
}
} else if (/\suses: actions\/setup-node/.test(line)) {
// Node can alternatively be referenced via the setup-node action, where
// the version is given as an input to the action. The input is not
// required. We're going to go looking for it in subsequent lines. If we
// find it, we'll include it; otherwise, we'll include a 0 to ensure the
// test fails because not supplying the version number is a bad practice
// Get the whitespace at the start of this line. We'll need it to figure
// out when we've exited this Yaml block.
const [leadingSpace] = line.match(/^\s+/);
// If the line we're currently on starts with a dash, then it is the
// start of a Yaml block, and the next Yaml block will begin with the
// same amount of whitespace. Otherwise, this line is inside an existing
// block and the next Yaml block will be de-indented two spaces from the
// current line. In either case, the next Yaml block will also begin
// with a dash. So here, we're building up a string that the next Yaml
// block will begin with.
const nextBreak = `${
line.trim().startsWith("-") ? leadingSpace : leadingSpace.slice(2)
}-`;
let nextLine = line;
let nextLineIndex = index;
// By default, assume the Node version is not set and prepare to return
// a value that will cause the tests to fail.
let nodeVersion = "not set";
// Now we're going to look at each line following the current one, up
// to the next Yaml block or the end of the file.
do {
// We're looking for an input named node-version. Make sure we ignore
// lines that are commented out.
const [, inputNodeVersion] =
nextLine.match(/^[^#]*node-version:\s*(\d*)/) ?? [];
// If we got a Node version, numberize it and bail out of this loop
// because we're done.
if (inputNodeVersion) {
nodeVersion = Number.parseInt(inputNodeVersion, 10);
break;
}
nextLineIndex += 1;
nextLine = lines[nextLineIndex];
// We're done when there aren't anymore lines, or when the next line
// starts with the right amount of whitespace and a dash, indicating
// the start of the next Yaml block.
} while (nextLine && !nextLine.startsWith(nextBreak));
// If we did not find a Node version, use the line of the action itself;
// otherwise, use the line where the Node version was set.
if (nodeVersion === "not set") {
linesWithInvalidNodeReferences.push([index + 1, nodeVersion]);
} else if (nodeVersion !== currentLTSVersion) {
linesWithInvalidNodeReferences.push([nextLineIndex + 1, nodeVersion]);
}
}
}
// If this workflow has lines that reference Node versions, add the workflow
// to the list of all workflows with Node references.
if (linesWithInvalidNodeReferences.length > 0) {
workflowFileWithInvalidNodeReferences.push([
ymlFilePath,
linesWithInvalidNodeReferences,
]);
}
}
return workflowFileWithInvalidNodeReferences;
};
const main = async () => {
const currentLTSVersion = await module.exports.getCurrentLTSVersion();
const engineMajor = await module.exports.getPackageNodeVersion();
const dockerMajor = await module.exports.getDockerNodeVersion();
const invalidWorkflowNodes =
await module.exports.getWorkflowLinesWithInvalidNodeVersion(
currentLTSVersion,
);
const text = [];
if (
engineMajor === currentLTSVersion &&
dockerMajor === currentLTSVersion &&
invalidWorkflowNodes.length === 0
) {
return text;
}
if (engineMajor !== currentLTSVersion) {
text.push("package.json is out of date");
text.push(` found Node ${engineMajor}; wanted ${currentLTSVersion}`);
}
if (dockerMajor !== currentLTSVersion) {
text.push("Dockerfile is out of date");
text.push(` found Node ${dockerMajor}; wanted ${currentLTSVersion}`);
}
for (const [file, lines] of invalidWorkflowNodes) {
text.push(`Workflow file ${file} is out of date`);
for (const [line, version] of lines) {
text.push(
` line ${line} uses Node ${version}; wanted ${currentLTSVersion}`,
);
}
}
return text;
};
module.exports = {
main,
getCurrentLTSVersion,
getPackageNodeVersion,
getDockerNodeVersion,
getWorkflowLinesWithInvalidNodeVersion,
};
if (!module.parent) {
module.exports.main().then((errors) => {
if (errors.length) {
console.log(errors.join("\n"));
process.exit(1);
}
});
}