The Node.js® Website
1#!/usr/bin/env node
2
3/**
4 * What's this?? It will help you create release blog
5 * posts so you won't have to do the tedious work
6 * of stitching together data from changelog, shasums etc,
7 * but get a more or less complete release blog ready to go.
8 *
9 * Usage: $ node index.mjs [version]
10 *
11 * If the version argument is omitted, the latest version number
12 * will be picked from https://nodejs.org/dist/index.json.
13 *
14 * It'll create a file with the blog post content
15 * into ../../pages/en/blog/release/vX.md ready for you to commit
16 * or possibly edit by hand before committing.
17 *
18 * Happy releasing!
19 */
20
21'use strict';
22
23import { existsSync, readFileSync } from 'node:fs';
24import { writeFile } from 'node:fs/promises';
25import { resolve } from 'node:path';
26
27import handlebars from 'handlebars';
28import { format } from 'prettier';
29
30import { downloadsTable } from './downloadsTable.mjs';
31import prettierConfig from '../../.prettierrc.json' assert { type: 'json' };
32import { getRelativePath } from '../../next.helpers.mjs';
33
34const URLS = {
35 NODE_DIST_JSON: 'https://nodejs.org/dist/index.json',
36 GITHUB_PROFILE: author => `https://api.github.com/users/${author}`,
37 NODE_CHANGELOG_MD: releaseLine =>
38 `https://raw.githubusercontent.com/nodejs/node/main/doc/changelogs/CHANGELOG_V${releaseLine}.md`,
39 NODE_SHASUM: version =>
40 `https://nodejs.org/dist/v${version}/SHASUMS256.txt.asc`,
41};
42
43const ERRORS = {
44 NO_VERSION_PROVIDED: new Error('No version provided'),
45 RELEASE_EXISTS: version =>
46 new Error(`Release post for ${version} already exists!`),
47 NO_AUTHOR_FOUND: version =>
48 new Error(`Couldn't find @author of ${version} release :(`),
49 NO_VERSION_POLICY: version =>
50 new Error(`Could not find version policy of ${version} in its changelog`),
51 NO_CHANGELOG_FOUND: version =>
52 new Error(`Couldn't find matching changelog for ${version}`),
53 INVALID_STATUS_CODE: (url, status) =>
54 new Error(`Invalid status (!= 200) while retrieving ${url}: ${status}`),
55 FAILED_FILE_FORMATTING: reason =>
56 new Error(`Failed to format Release post: Reason: ${reason}`),
57 FAILED_FILE_CREATION: reason =>
58 new Error(`Failed to write Release post: Reason: ${reason}`),
59};
60
61const ARGS = {
62 CURRENT_PATH: process.argv[1],
63 SPECIFIC_VERSION: process.argv[2] && process.argv[2].replace('--force', ''),
64 SHOULD_FORCE: (process.argv[3] || process.argv[2]) === '--force',
65};
66
67// this allows us to get the current module working directory
68const __dirname = getRelativePath(import.meta.url);
69
70const request = options => {
71 return fetch(options.url, options).then(resp => {
72 if (resp.status !== 200) {
73 throw ERRORS.INVALID_STATUS_CODE(options.url, resp.status);
74 }
75
76 return options.json ? resp.json() : resp.text();
77 });
78};
79
80const explicitVersion = version =>
81 new Promise((resolve, reject) =>
82 version && version.length > 0
83 ? resolve(version)
84 : reject(ERRORS.NO_VERSION_PROVIDED)
85 );
86
87const findLatestVersion = () =>
88 request({ url: URLS.NODE_DIST_JSON, json: true })
89 .then(versions => versions.length && versions[0])
90 .then(({ version }) => version.substr(1));
91
92const fetchDocs = version => {
93 const blogPostPieces = [
94 fetchChangelogBody(version),
95 fetchAuthor(version),
96 fetchVersionPolicy(version),
97 fetchShasums(version),
98 verifyDownloads(version),
99 ];
100
101 return Promise.all(blogPostPieces).then(
102 ([changelog, author, versionPolicy, shasums, files]) => ({
103 version,
104 changelog,
105 author,
106 versionPolicy,
107 shasums,
108 files,
109 })
110 );
111};
112
113const fetchAuthor = version => {
114 return fetchChangelog(version)
115 .then(section => findAuthorLogin(version, section))
116 .then(author => request({ url: URLS.GITHUB_PROFILE(author), json: true }))
117 .then(githubRes => githubRes.name);
118};
119
120const fetchChangelog = version => {
121 const parts = version.split('.');
122 const releaseLine = parts[0] === '0' ? parts.slice(0, 2).join('') : parts[0];
123
124 return request({ url: URLS.NODE_CHANGELOG_MD(releaseLine) }).then(data => {
125 // matches a complete release section
126 const rxSection = new RegExp(
127 `<a id="${version}"></a>\\n([\\s\\S]+?)(?:\\n<a id="|$)`
128 );
129
130 const matches = rxSection.exec(data);
131
132 return new Promise((resolve, reject) =>
133 matches && matches.length && matches[1]
134 ? resolve(matches[1].trim())
135 : reject(ERRORS.NO_CHANGELOG_FOUND(version))
136 );
137 });
138};
139
140const fetchChangelogBody = version => {
141 return fetchChangelog(version).then(section => {
142 const replaceAsteriskLists = str =>
143 str.replace(/^([ ]{0,4})(\* )/gm, '$1- ');
144
145 return new Promise(resolve =>
146 resolve(replaceAsteriskLists(section.trim()))
147 );
148 });
149};
150
151const fetchVersionPolicy = version => {
152 return fetchChangelog(version).then(section => {
153 // matches the policy for a given version (Stable, LTS etc) in the changelog
154 // ## 2015-10-07, Version 4.2.0 'Argon' (LTS), @jasnell
155 // ## 2015-12-04, Version 0.12.9 (LTS), @rvagg
156 const rxPolicy = /^## ?\d{4}-\d{2}-\d{2}, Version [^(].*\(([^)]+)\)/;
157 const matches = rxPolicy.exec(section);
158
159 return new Promise((resolve, reject) =>
160 matches && matches.length && matches[1]
161 ? resolve(matches[1])
162 : reject(ERRORS.NO_VERSION_POLICY(version))
163 );
164 });
165};
166
167const fetchShasums = version =>
168 request({ url: URLS.NODE_SHASUM(version) }).then(
169 result => result.trim(),
170 () => '[INSERT SHASUMS HERE]'
171 );
172
173const verifyDownloads = version =>
174 Promise.all(downloadsTable(version).map(urlOrComingSoon));
175
176const findAuthorLogin = (version, section) => {
177 // looking for the @author part of the release header, eg:
178 // ## 2016-03-08, Version 5.8.0 (Stable). @Fishrock123
179 // ## 2015-10-13, Version 4.2.1 'Argon' (LTS), @jasnell
180 // ## 2015-09-08, Version 4.0.0 (Stable), @rvagg
181 const rxReleaseAuthor = /^## .*? \([^)]+\)[,.] @(\S+)/;
182 const matches = rxReleaseAuthor.exec(section);
183
184 return new Promise((resolve, reject) =>
185 matches && matches.length && matches[1]
186 ? resolve(matches[1])
187 : reject(ERRORS.RELEASE_EXISTS(version))
188 );
189};
190
191const urlOrComingSoon = binary => {
192 return request({ url: binary.url, method: 'HEAD' }).then(
193 () => `${binary.title}: ${binary.url}`,
194 () => `${binary.title}: *Coming soon*`
195 );
196};
197
198const renderPost = results => {
199 const blogTemplateSource = readFileSync(
200 resolve(__dirname, 'template.hbs'),
201 'utf8'
202 );
203
204 const template = handlebars.compile(blogTemplateSource, { noEscape: true });
205
206 const templateParameters = {
207 date: new Date().toISOString(),
208 versionSlug: slugify(results.version),
209 ...results,
210 };
211
212 return { content: template(templateParameters), ...results };
213};
214
215const formatPost = results => {
216 return new Promise((resolve, reject) => {
217 format(results.content, { ...prettierConfig, parser: 'markdown' })
218 .then(content => resolve({ ...results, content }))
219 .catch(error => reject(ERRORS.FAILED_FILE_FORMATTING(error.message)));
220 });
221};
222
223const writeToFile = results => {
224 const blogPostPath = resolve(
225 __dirname,
226 '../../pages/en/blog/release',
227 `v${results.version}.md`
228 );
229
230 return new Promise((resolve, reject) => {
231 if (existsSync(blogPostPath) && !ARGS.SHOULD_FORCE) {
232 reject(ERRORS.RELEASE_EXISTS(results.version));
233 return;
234 }
235
236 writeFile(blogPostPath, results.content)
237 .then(() => resolve(blogPostPath))
238 .catch(error => reject(ERRORS.FAILED_FILE_CREATION(error.message)));
239 });
240};
241
242const slugify = str => str.replace(/\./g, '-');
243
244export {
245 explicitVersion,
246 fetchShasums,
247 writeToFile,
248 findLatestVersion,
249 verifyDownloads,
250 fetchChangelog,
251 fetchChangelogBody,
252 fetchAuthor,
253 fetchVersionPolicy,
254};
255
256// This allows us to verify that the script is being run directly from node.js/cli
257if (import.meta.url.startsWith('file:')) {
258 if (ARGS.CURRENT_PATH === `${__dirname}index.mjs`) {
259 explicitVersion(ARGS.SPECIFIC_VERSION)
260 .then(null, findLatestVersion)
261 .then(fetchDocs)
262 .then(renderPost)
263 .then(formatPost)
264 .then(writeToFile)
265 .then(
266 filepath => console.log('Release post created:', filepath),
267 error => console.error('Some error occurred here!', error.stack)
268 );
269 }
270}