The Node.js® Website
at main 8.2 kB view raw
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}