Browse Source

build: convert all release scripts to typescript (#44035)

* build: convert all release scripts to typescript

* fix test imports

* build: fix version bumper export

* refactor: use as const

* spec: fix bad type spec
Samuel Attard 6 months ago
parent
commit
61565465fd

+ 1 - 2
package.json

@@ -25,7 +25,6 @@
     "buffer": "^6.0.3",
     "chalk": "^4.1.0",
     "check-for-leaks": "^1.2.1",
-    "dotenv-safe": "^4.0.4",
     "dugite": "^2.7.1",
     "eslint": "^8.57.1",
     "eslint-config-standard": "^17.1.0",
@@ -57,7 +56,7 @@
     "timers-browserify": "1.4.2",
     "ts-loader": "^8.0.2",
     "ts-node": "6.2.0",
-    "typescript": "^5.1.2",
+    "typescript": "^5.6.2",
     "url": "^0.11.4",
     "webpack": "^5.94.0",
     "webpack-cli": "^5.1.4",

+ 0 - 2
script/prepare-appveyor.js

@@ -1,5 +1,3 @@
-if (!process.env.CI) require('dotenv-safe').load();
-
 const assert = require('node:assert');
 const fs = require('node:fs');
 const got = require('got');

+ 122 - 52
script/release/ci-release-build.js → script/release/ci-release-build.ts

@@ -1,19 +1,19 @@
-if (!process.env.CI) require('dotenv-safe').load();
+import { Octokit } from '@octokit/rest';
+import got, { OptionsOfTextResponseBody } from 'got';
+import * as assert from 'node:assert';
 
-const assert = require('node:assert');
-const got = require('got');
+import { createGitHubTokenStrategy } from './github-token';
+import { ELECTRON_ORG, ELECTRON_REPO } from './types';
+import { parseArgs } from 'node:util';
 
-const { Octokit } = require('@octokit/rest');
-const { createGitHubTokenStrategy } = require('./github-token');
 const octokit = new Octokit({
   authStrategy: createGitHubTokenStrategy('electron')
 });
 
 const BUILD_APPVEYOR_URL = 'https://ci.appveyor.com/api/builds';
 const GH_ACTIONS_PIPELINE_URL = 'https://github.com/electron/electron/actions';
-const GH_ACTIONS_API_URL = '/repos/electron/electron/actions';
 
-const GH_ACTIONS_WAIT_TIME = process.env.GH_ACTIONS_WAIT_TIME || 30000;
+const GH_ACTIONS_WAIT_TIME = process.env.GH_ACTIONS_WAIT_TIME ? parseInt(process.env.GH_ACTIONS_WAIT_TIME, 10) : 30000;
 
 const appVeyorJobs = {
   'electron-x64': 'electron-x64-release',
@@ -24,11 +24,21 @@ const appVeyorJobs = {
 const ghActionsPublishWorkflows = [
   'linux-publish',
   'macos-publish'
-];
+] as const;
 
 let jobRequestedCount = 0;
 
-async function makeRequest ({ auth, username, password, url, headers, body, method }) {
+type ReleaseBuildRequestOptions = {
+  auth?: {
+    bearer?: string;
+  };
+  url: string;
+  headers: Record<string, string>;
+  body: string,
+  method: 'GET' | 'POST';
+}
+
+async function makeRequest ({ auth, url, headers, body, method }: ReleaseBuildRequestOptions) {
   const clonedHeaders = {
     ...(headers || {})
   };
@@ -36,17 +46,12 @@ async function makeRequest ({ auth, username, password, url, headers, body, meth
     clonedHeaders.Authorization = `Bearer ${auth.bearer}`;
   }
 
-  const options = {
+  const options: OptionsOfTextResponseBody = {
     headers: clonedHeaders,
     body,
     method
   };
 
-  if (username || password) {
-    options.username = username;
-    options.password = password;
-  }
-
   const response = await got(url, options);
 
   if (response.statusCode < 200 || response.statusCode >= 300) {
@@ -56,11 +61,17 @@ async function makeRequest ({ auth, username, password, url, headers, body, meth
   return JSON.parse(response.body);
 }
 
-async function githubActionsCall (targetBranch, workflowName, options) {
+type GitHubActionsCallOptions = {
+  ghRelease?: boolean;
+  newVersion: string;
+  runningPublishWorkflows?: boolean;
+}
+
+async function githubActionsCall (targetBranch: string, workflowName: string, options: GitHubActionsCallOptions) {
   console.log(`Triggering GitHub Actions to run build job: ${workflowName} on branch: ${targetBranch} with release flag.`);
   const buildRequest = {
     branch: targetBranch,
-    parameters: {}
+    parameters: {} as Record<string, string | boolean>
   };
   if (options.ghRelease) {
     buildRequest.parameters['upload-to-storage'] = '0';
@@ -81,13 +92,13 @@ async function githubActionsCall (targetBranch, workflowName, options) {
       console.error('Could not fetch most recent commits for GitHub Actions, returning early');
     }
 
-    await octokit.request(`POST ${GH_ACTIONS_API_URL}/workflows/${workflowName}.yml/dispatches`, {
+    await octokit.actions.createWorkflowDispatch({
+      repo: ELECTRON_REPO,
+      owner: ELECTRON_ORG,
+      workflow_id: `${workflowName}.yml`,
       ref: `refs/tags/${options.newVersion}`,
       inputs: {
         ...buildRequest.parameters
-      },
-      headers: {
-        'X-GitHub-Api-Version': '2022-11-28'
       }
     });
 
@@ -110,17 +121,18 @@ async function githubActionsCall (targetBranch, workflowName, options) {
   }
 }
 
-async function getGitHubActionsRun (workflowId, headCommit) {
+async function getGitHubActionsRun (workflowName: string, headCommit: string) {
   let runNumber = 0;
   let actionRun;
   while (runNumber === 0) {
-    const actionsRuns = await octokit.request(`GET ${GH_ACTIONS_API_URL}/workflows/${workflowId}.yml/runs`, {
-      headers: {
-        'X-GitHub-Api-Version': '2022-11-28'
-      }
+    const actionsRuns = await octokit.actions.listWorkflowRuns({
+      repo: ELECTRON_REPO,
+      owner: ELECTRON_ORG,
+      workflow_id: `${workflowName}.yml`
     });
+
     if (!actionsRuns.data.workflow_runs.length) {
-      console.log(`No current workflow_runs found for ${workflowId}, response was: ${actionsRuns.data.workflow_runs}`);
+      console.log(`No current workflow_runs found for ${workflowName}, response was: ${actionsRuns.data.workflow_runs}`);
       runNumber = -1;
       break;
     }
@@ -163,9 +175,14 @@ async function getGitHubActionsRun (workflowId, headCommit) {
   return runNumber;
 }
 
-async function callAppVeyor (targetBranch, job, options) {
+type AppVeyorCallOptions = {
+  ghRelease?: boolean;
+  commit?: string;
+}
+
+async function callAppVeyor (targetBranch: string, job: keyof typeof appVeyorJobs, options: AppVeyorCallOptions) {
   console.log(`Triggering AppVeyor to run build job: ${job} on branch: ${targetBranch} with release flag.`);
-  const environmentVariables = {
+  const environmentVariables: Record<string, string | number> = {
     ELECTRON_RELEASE: 1,
     APPVEYOR_BUILD_WORKER_CLOUD: 'electronhq-16-core'
   };
@@ -190,14 +207,14 @@ async function callAppVeyor (targetBranch, job, options) {
       environmentVariables
     }),
     method: 'POST'
-  };
+  } as const;
   jobRequestedCount++;
 
   try {
-    const { version } = await makeRequest(requestOpts, true);
+    const { version } = await makeRequest(requestOpts);
     const buildUrl = `https://ci.appveyor.com/project/electron-bot/${appVeyorJobs[job]}/build/${version}`;
     console.log(`AppVeyor release build request for ${job} successful.  Check build status at ${buildUrl}`);
-  } catch (err) {
+  } catch (err: any) {
     if (err.response?.body) {
       console.error('Could not call AppVeyor: ', {
         statusCode: err.response.statusCode,
@@ -209,67 +226,120 @@ async function callAppVeyor (targetBranch, job, options) {
   }
 }
 
-function buildAppVeyor (targetBranch, options) {
-  const validJobs = Object.keys(appVeyorJobs);
+type BuildAppVeyorOptions = {
+  job?: keyof typeof appVeyorJobs;
+} & AppVeyorCallOptions;
+
+async function buildAppVeyor (targetBranch: string, options: BuildAppVeyorOptions) {
+  const validJobs = Object.keys(appVeyorJobs) as (keyof typeof appVeyorJobs)[];
   if (options.job) {
     assert(validJobs.includes(options.job), `Unknown AppVeyor CI job name: ${options.job}.  Valid values are: ${validJobs}.`);
-    callAppVeyor(targetBranch, options.job, options);
+    await callAppVeyor(targetBranch, options.job, options);
   } else {
     for (const job of validJobs) {
-      callAppVeyor(targetBranch, job, options);
+      await callAppVeyor(targetBranch, job, options);
     }
   }
 }
 
-function buildGHActions (targetBranch, options) {
+type BuildGHActionsOptions = {
+  job?: typeof ghActionsPublishWorkflows[number];
+  arch?: string;
+} & GitHubActionsCallOptions;
+
+async function buildGHActions (targetBranch: string, options: BuildGHActionsOptions) {
   if (options.job) {
     assert(ghActionsPublishWorkflows.includes(options.job), `Unknown GitHub Actions workflow name: ${options.job}. Valid values are: ${ghActionsPublishWorkflows}.`);
-    githubActionsCall(targetBranch, options.job, options);
+    await githubActionsCall(targetBranch, options.job, options);
   } else {
     assert(!options.arch, 'Cannot provide a single architecture while building all workflows, please specify a single workflow via --workflow');
     options.runningPublishWorkflows = true;
     for (const job of ghActionsPublishWorkflows) {
-      githubActionsCall(targetBranch, job, options);
+      await githubActionsCall(targetBranch, job, options);
     }
   }
 }
 
-function runRelease (targetBranch, options) {
+type RunReleaseOptions = ({
+  ci: 'GitHubActions'
+} & BuildGHActionsOptions) | ({
+  ci: 'AppVeyor'
+} & BuildAppVeyorOptions) | ({
+  ci: undefined,
+} & BuildAppVeyorOptions & BuildGHActionsOptions);
+
+async function runRelease (targetBranch: string, options: RunReleaseOptions) {
   if (options.ci) {
     switch (options.ci) {
       case 'GitHubActions': {
-        buildGHActions(targetBranch, options);
+        await buildGHActions(targetBranch, options);
         break;
       }
       case 'AppVeyor': {
-        buildAppVeyor(targetBranch, options);
+        await buildAppVeyor(targetBranch, options);
         break;
       }
       default: {
-        console.log(`Error! Unknown CI: ${options.ci}.`);
+        console.log(`Error! Unknown CI: ${(options as any).ci}.`);
         process.exit(1);
       }
     }
   } else {
-    buildAppVeyor(targetBranch, options);
-    buildGHActions(targetBranch, options);
+    await Promise.all([
+      buildAppVeyor(targetBranch, options),
+      buildGHActions(targetBranch, options)
+    ]);
   }
   console.log(`${jobRequestedCount} jobs were requested.`);
 }
 
-module.exports = runRelease;
+export default runRelease;
 
 if (require.main === module) {
-  const args = require('minimist')(process.argv.slice(2), {
-    boolean: ['ghRelease']
+  const { values: { ghRelease, job, arch, ci, commit, newVersion }, positionals } = parseArgs({
+    options: {
+      ghRelease: {
+        type: 'boolean'
+      },
+      job: {
+        type: 'string'
+      },
+      arch: {
+        type: 'string'
+      },
+      ci: {
+        type: 'string'
+      },
+      commit: {
+        type: 'string'
+      },
+      newVersion: {
+        type: 'string'
+      }
+    },
+    allowPositionals: true
   });
-  const targetBranch = args._[0];
-  if (args._.length < 1) {
+  const targetBranch = positionals[0];
+  if (positionals.length < 1) {
     console.log(`Trigger CI to build release builds of electron.
     Usage: ci-release-build.js [--job=CI_JOB_NAME] [--arch=INDIVIDUAL_ARCH] [--ci=AppVeyor|GitHubActions]
-    [--ghRelease] [--appveyorJobId=xxx] [--commit=sha] TARGET_BRANCH
+    [--ghRelease] [--commit=sha] [--newVersion=version_tag] TARGET_BRANCH
     `);
     process.exit(0);
   }
-  runRelease(targetBranch, args);
+  if (ci === 'GitHubActions' || !ci) {
+    if (!newVersion) {
+      console.error('--newVersion is required for GitHubActions');
+      process.exit(1);
+    }
+  }
+
+  runRelease(targetBranch, {
+    ci: ci as 'GitHubActions' | 'AppVeyor',
+    ghRelease,
+    job: job as any,
+    arch,
+    newVersion: newVersion!,
+    commit
+  });
 }

+ 11 - 8
script/release/find-github-release.js → script/release/find-github-release.ts

@@ -1,7 +1,6 @@
-if (!process.env.CI) require('dotenv-safe').load();
-
-const { Octokit } = require('@octokit/rest');
-const { createGitHubTokenStrategy } = require('./github-token');
+import { Octokit } from '@octokit/rest';
+import { createGitHubTokenStrategy } from './github-token';
+import { ELECTRON_ORG, ELECTRON_REPO, ElectronReleaseRepo, NIGHTLY_REPO } from './types';
 
 if (process.argv.length < 3) {
   console.log('Usage: find-release version');
@@ -15,13 +14,13 @@ const octokit = new Octokit({
   authStrategy: createGitHubTokenStrategy(targetRepo)
 });
 
-function findRepo () {
-  return version.indexOf('nightly') > 0 ? 'nightlies' : 'electron';
+function findRepo (): ElectronReleaseRepo {
+  return version.indexOf('nightly') > 0 ? NIGHTLY_REPO : ELECTRON_REPO;
 }
 
 async function findRelease () {
   const releases = await octokit.repos.listReleases({
-    owner: 'electron',
+    owner: ELECTRON_ORG,
     repo: targetRepo
   });
 
@@ -43,4 +42,8 @@ async function findRelease () {
   console.log(JSON.stringify(returnObject));
 }
 
-findRelease();
+findRelease()
+  .catch((err) => {
+    console.error(err);
+    process.exit(1);
+  });

+ 7 - 10
script/release/get-asset.js → script/release/get-asset.ts

@@ -1,8 +1,9 @@
-const { Octokit } = require('@octokit/rest');
-const got = require('got');
-const { createGitHubTokenStrategy } = require('./github-token');
+import { Octokit } from '@octokit/rest';
+import got from 'got';
+import { createGitHubTokenStrategy } from './github-token';
+import { ElectronReleaseRepo } from './types';
 
-async function getAssetContents (repo, assetId) {
+export async function getAssetContents (repo: ElectronReleaseRepo, assetId: number) {
   const octokit = new Octokit({
     userAgent: 'electron-asset-fetcher',
     authStrategy: createGitHubTokenStrategy(repo)
@@ -18,12 +19,12 @@ async function getAssetContents (repo, assetId) {
   });
 
   const { url, headers } = requestOptions;
-  headers.authorization = `token ${(await octokit.auth()).token}`;
+  headers.authorization = `token ${(await octokit.auth() as { token: string }).token}`;
 
   const response = await got(url, {
     followRedirect: false,
     method: 'HEAD',
-    headers,
+    headers: headers as Record<string, string>,
     throwHttpErrors: false
   });
 
@@ -48,7 +49,3 @@ async function getAssetContents (repo, assetId) {
 
   return fileResponse.body;
 }
-
-module.exports = {
-  getAssetContents
-};

+ 13 - 9
script/release/get-url-hash.js → script/release/get-url-hash.ts

@@ -1,17 +1,20 @@
-const got = require('got');
-const url = require('node:url');
+import got from 'got';
+import * as url from 'node:url';
 
-module.exports = async function getUrlHash (targetUrl, algorithm = 'sha256', attempts = 3) {
+const HASHER_FUNCTION_HOST = 'electron-artifact-hasher.azurewebsites.net';
+const HASHER_FUNCTION_ROUTE = '/api/HashArtifact';
+
+export async function getUrlHash (targetUrl: string, algorithm = 'sha256', attempts = 3) {
   const options = {
-    code: process.env.ELECTRON_ARTIFACT_HASHER_FUNCTION_KEY,
+    code: process.env.ELECTRON_ARTIFACT_HASHER_FUNCTION_KEY!,
     targetUrl,
     algorithm
   };
   const search = new url.URLSearchParams(options);
   const functionUrl = url.format({
     protocol: 'https:',
-    hostname: 'electron-artifact-hasher.azurewebsites.net',
-    pathname: '/api/HashArtifact',
+    hostname: HASHER_FUNCTION_HOST,
+    pathname: HASHER_FUNCTION_ROUTE,
     search: search.toString()
   });
   try {
@@ -27,10 +30,11 @@ module.exports = async function getUrlHash (targetUrl, algorithm = 'sha256', att
     return resp.body.trim();
   } catch (err) {
     if (attempts > 1) {
-      if (err.response?.body) {
+      const { response } = err as any;
+      if (response?.body) {
         console.error(`Failed to get URL hash for ${targetUrl} - we will retry`, {
-          statusCode: err.response.statusCode,
-          body: JSON.parse(err.response.body)
+          statusCode: response.statusCode,
+          body: JSON.parse(response.body)
         });
       } else {
         console.error(`Failed to get URL hash for ${targetUrl} - we will retry`, err);

+ 14 - 11
script/release/github-token.js → script/release/github-token.ts

@@ -1,9 +1,11 @@
-const { createTokenAuth } = require('@octokit/auth-token');
-const got = require('got').default;
+import { createTokenAuth } from '@octokit/auth-token';
+import got from 'got';
+
+import { ElectronReleaseRepo } from './types';
 
 const cachedTokens = Object.create(null);
 
-async function ensureToken (repo) {
+async function ensureToken (repo: ElectronReleaseRepo) {
   if (!cachedTokens[repo]) {
     cachedTokens[repo] = await (async () => {
       const { ELECTRON_GITHUB_TOKEN, SUDOWOODO_EXCHANGE_URL, SUDOWOODO_EXCHANGE_TOKEN } = process.env;
@@ -35,23 +37,24 @@ async function ensureToken (repo) {
   }
 }
 
-module.exports.createGitHubTokenStrategy = (repo) => () => {
-  let tokenAuth = null;
+export const createGitHubTokenStrategy = (repo: ElectronReleaseRepo) => () => {
+  let tokenAuth: ReturnType<typeof createTokenAuth> | null = null;
 
-  async function ensureTokenAuth () {
+  async function ensureTokenAuth (): Promise<ReturnType<typeof createTokenAuth>> {
     if (!tokenAuth) {
       await ensureToken(repo);
       tokenAuth = createTokenAuth(cachedTokens[repo]);
     }
+    return tokenAuth;
   }
 
   async function auth () {
-    await ensureTokenAuth();
-    return await tokenAuth();
+    return await (await ensureTokenAuth())();
   }
-  auth.hook = async (...args) => {
-    await ensureTokenAuth();
-    return await tokenAuth.hook(...args);
+  const hook: ReturnType<typeof createTokenAuth>['hook'] = async (...args) => {
+    const a = (await ensureTokenAuth());
+    return (a as any).hook(...args);
   };
+  auth.hook = hook;
   return auth;
 };

+ 51 - 40
script/release/notes/index.js → script/release/notes/index.ts

@@ -1,22 +1,22 @@
 #!/usr/bin/env node
 
-const { GitProcess } = require('dugite');
-const minimist = require('minimist');
-const path = require('node:path');
-const semver = require('semver');
+import { GitProcess } from 'dugite';
+import { basename } from 'node:path';
+import { valid, compare, gte, lte } from 'semver';
 
-const { ELECTRON_DIR } = require('../../lib/utils');
-const notesGenerator = require('./notes.js');
+import { ELECTRON_DIR } from '../../lib/utils';
+import { get, render } from './notes';
 
-const { Octokit } = require('@octokit/rest');
-const { createGitHubTokenStrategy } = require('../github-token');
+import { Octokit } from '@octokit/rest';
+import { createGitHubTokenStrategy } from '../github-token';
+import { parseArgs } from 'node:util';
 const octokit = new Octokit({
   authStrategy: createGitHubTokenStrategy('electron')
 });
 
-const semverify = version => version.replace(/^origin\//, '').replace(/[xy]/g, '0').replace(/-/g, '.');
+const semverify = (version: string) => version.replace(/^origin\//, '').replace(/[xy]/g, '0').replace(/-/g, '.');
 
-const runGit = async (args) => {
+const runGit = async (args: string[]) => {
   console.info(`Running: git ${args.join(' ')}`);
   const response = await GitProcess.exec(args, ELECTRON_DIR);
   if (response.exitCode !== 0) {
@@ -25,25 +25,25 @@ const runGit = async (args) => {
   return response.stdout.trim();
 };
 
-const tagIsSupported = tag => tag && !tag.includes('nightly') && !tag.includes('unsupported');
-const tagIsAlpha = tag => tag && tag.includes('alpha');
-const tagIsBeta = tag => tag && tag.includes('beta');
-const tagIsStable = tag => tagIsSupported(tag) && !tagIsBeta(tag) && !tagIsAlpha(tag);
+const tagIsSupported = (tag: string) => !!tag && !tag.includes('nightly') && !tag.includes('unsupported');
+const tagIsAlpha = (tag: string) => !!tag && tag.includes('alpha');
+const tagIsBeta = (tag: string) => !!tag && tag.includes('beta');
+const tagIsStable = (tag: string) => tagIsSupported(tag) && !tagIsBeta(tag) && !tagIsAlpha(tag);
 
-const getTagsOf = async (point) => {
+const getTagsOf = async (point: string) => {
   try {
     const tags = await runGit(['tag', '--merged', point]);
     return tags.split('\n')
       .map(tag => tag.trim())
-      .filter(tag => semver.valid(tag))
-      .sort(semver.compare);
+      .filter(tag => valid(tag))
+      .sort(compare);
   } catch (err) {
     console.error(`Failed to fetch tags for point ${point}`);
     throw err;
   }
 };
 
-const getTagsOnBranch = async (point) => {
+const getTagsOnBranch = async (point: string) => {
   const { data: { default_branch: defaultBranch } } = await octokit.repos.get({
     owner: 'electron',
     repo: 'electron'
@@ -57,7 +57,7 @@ const getTagsOnBranch = async (point) => {
   return (await getTagsOf(point)).filter(tag => !mainTagsSet.has(tag));
 };
 
-const getBranchOf = async (point) => {
+const getBranchOf = async (point: string) => {
   try {
     const branches = (await runGit(['branch', '-a', '--contains', point]))
       .split('\n')
@@ -89,11 +89,11 @@ const getStabilizationBranches = async () => {
   return (await getAllBranches()).filter(branch => /^origin\/\d+-x-y$/.test(branch));
 };
 
-const getPreviousStabilizationBranch = async (current) => {
+const getPreviousStabilizationBranch = async (current: string) => {
   const stabilizationBranches = (await getStabilizationBranches())
     .filter(branch => branch !== current && branch !== `origin/${current}`);
 
-  if (!semver.valid(current)) {
+  if (!valid(current)) {
     // since we don't seem to be on a stabilization branch right now,
     // pick a placeholder name that will yield the newest branch
     // as a comparison point.
@@ -102,20 +102,20 @@ const getPreviousStabilizationBranch = async (current) => {
 
   let newestMatch = null;
   for (const branch of stabilizationBranches) {
-    if (semver.gte(semverify(branch), semverify(current))) {
+    if (gte(semverify(branch), semverify(current))) {
       continue;
     }
-    if (newestMatch && semver.lte(semverify(branch), semverify(newestMatch))) {
+    if (newestMatch && lte(semverify(branch), semverify(newestMatch))) {
       continue;
     }
     newestMatch = branch;
   }
-  return newestMatch;
+  return newestMatch!;
 };
 
-const getPreviousPoint = async (point) => {
+const getPreviousPoint = async (point: string) => {
   const currentBranch = await getBranchOf(point);
-  const currentTag = (await getTagsOf(point)).filter(tag => tagIsSupported(tag)).pop();
+  const currentTag = (await getTagsOf(point)).filter(tag => tagIsSupported(tag)).pop()!;
   const currentIsStable = tagIsStable(currentTag);
 
   try {
@@ -146,18 +146,18 @@ const getPreviousPoint = async (point) => {
   }
 };
 
-async function getReleaseNotes (range, newVersion, unique) {
+async function getReleaseNotes (range: string, newVersion?: string, unique?: boolean) {
   const rangeList = range.split('..') || ['HEAD'];
-  const to = rangeList.pop();
-  const from = rangeList.pop() || (await getPreviousPoint(to));
+  const to = rangeList.pop()!;
+  const from = rangeList.pop() || (await getPreviousPoint(to))!;
 
   if (!newVersion) {
     newVersion = to;
   }
 
-  const notes = await notesGenerator.get(from, to, newVersion);
-  const ret = {
-    text: notesGenerator.render(notes, unique)
+  const notes = await get(from, to, newVersion);
+  const ret: { text: string; warning?: string; } = {
+    text: render(notes, unique)
   };
 
   if (notes.unknown.length) {
@@ -168,13 +168,24 @@ async function getReleaseNotes (range, newVersion, unique) {
 }
 
 async function main () {
-  const opts = minimist(process.argv.slice(2), {
-    boolean: ['help', 'unique'],
-    string: ['version']
+  const { values: { help, unique, version }, positionals } = parseArgs({
+    options: {
+      help: {
+        type: 'boolean'
+      },
+      unique: {
+        type: 'boolean'
+      },
+      version: {
+        type: 'string'
+      }
+    },
+    allowPositionals: true
   });
-  opts.range = opts._.shift();
-  if (opts.help || !opts.range) {
-    const name = path.basename(process.argv[1]);
+
+  const range = positionals.shift();
+  if (help || !range) {
+    const name = basename(process.argv[1]);
     console.log(`
 easy usage: ${name} version
 
@@ -194,7 +205,7 @@ For example, these invocations are equivalent:
     return 0;
   }
 
-  const notes = await getReleaseNotes(opts.range, opts.version, opts.unique);
+  const notes = await getReleaseNotes(range, version, unique);
   console.log(notes.text);
   if (notes.warning) {
     throw new Error(notes.warning);
@@ -208,4 +219,4 @@ if (require.main === module) {
   });
 }
 
-module.exports = getReleaseNotes;
+export default getReleaseNotes;

+ 296 - 165
script/release/notes/notes.js → script/release/notes/notes.ts

@@ -1,16 +1,13 @@
 #!/usr/bin/env node
 
-'use strict';
+import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
+import { resolve as _resolve } from 'node:path';
 
-const fs = require('node:fs');
-const path = require('node:path');
+import { Octokit } from '@octokit/rest';
+import { GitProcess } from 'dugite';
 
-const { GitProcess } = require('dugite');
-
-const { Octokit } = require('@octokit/rest');
-
-const { ELECTRON_DIR } = require('../../lib/utils');
-const { createGitHubTokenStrategy } = require('../github-token');
+import { ELECTRON_DIR } from '../../lib/utils';
+import { createGitHubTokenStrategy } from '../github-token';
 
 const octokit = new Octokit({
   authStrategy: createGitHubTokenStrategy('electron')
@@ -26,24 +23,52 @@ const NO_NOTES = 'No notes';
 const docTypes = new Set(['doc', 'docs']);
 const featTypes = new Set(['feat', 'feature']);
 const fixTypes = new Set(['fix']);
-const otherTypes = new Set(['spec', 'build', 'test', 'chore', 'deps', 'refactor', 'tools', 'perf', 'style', 'ci']);
-const knownTypes = new Set([...docTypes.keys(), ...featTypes.keys(), ...fixTypes.keys(), ...otherTypes.keys()]);
-
-const getCacheDir = () => process.env.NOTES_CACHE_PATH || path.resolve(__dirname, '.cache');
+const otherTypes = new Set([
+  'spec',
+  'build',
+  'test',
+  'chore',
+  'deps',
+  'refactor',
+  'tools',
+  'perf',
+  'style',
+  'ci'
+]);
+const knownTypes = new Set([
+  ...docTypes.keys(),
+  ...featTypes.keys(),
+  ...fixTypes.keys(),
+  ...otherTypes.keys()
+]);
+
+const getCacheDir = () =>
+  process.env.NOTES_CACHE_PATH || _resolve(__dirname, '.cache');
 
 /**
-***
-**/
+ ***
+ **/
+
+type MinimalPR = {
+  title: string;
+  body: string | null;
+  number: number;
+  labels: {
+    name: string;
+  }[];
+  base: { repo: { name: string; owner: { login: string } } };
+};
 
 // link to a GitHub item, e.g. an issue or pull request
 class GHKey {
-  constructor (owner, repo, number) {
-    this.owner = owner;
-    this.repo = repo;
-    this.number = number;
-  }
-
-  static NewFromPull (pull) {
+  // eslint-disable-next-line no-useless-constructor
+  constructor (
+    public readonly owner: string,
+    public readonly repo: string,
+    public readonly number: number
+  ) {}
+
+  static NewFromPull (pull: MinimalPR) {
     const owner = pull.base.repo.owner.login;
     const repo = pull.base.repo.name;
     const number = pull.number;
@@ -52,38 +77,33 @@ class GHKey {
 }
 
 class Commit {
-  constructor (hash, owner, repo) {
-    this.hash = hash; // string
-    this.owner = owner; // string
-    this.repo = repo; // string
-
-    this.isBreakingChange = false;
-    this.note = null; // string
-
-    // A set of branches to which this change has been merged.
-    // '8-x-y' => GHKey { owner: 'electron', repo: 'electron', number: 23714 }
-    this.trops = new Map(); // Map<string,GHKey>
-
-    this.prKeys = new Set(); // GHKey
-    this.revertHash = null; // string
-    this.semanticType = null; // string
-    this.subject = null; // string
-  }
+  public isBreakingChange = false;
+  public note: string | null = null;
+  public trops = new Map<string, GHKey>();
+  public readonly prKeys = new Set<GHKey>();
+  public revertHash: string | null = null;
+  public semanticType: string | null = null;
+  public subject: string | null = null;
+
+  // eslint-disable-next-line no-useless-constructor
+  constructor (
+    public readonly hash: string,
+    public readonly owner: string,
+    public readonly repo: string
+  ) {}
 }
 
 class Pool {
-  constructor () {
-    this.commits = []; // Array<Commit>
-    this.processedHashes = new Set();
-    this.pulls = {}; // GHKey.number => octokit pull object
-  }
+  public commits: Commit[] = [];
+  public processedHashes = new Set<string>();
+  public pulls: Record<number, MinimalPR> = Object.create(null);
 }
 
 /**
-***
-**/
+ ***
+ **/
 
-const runGit = async (dir, args) => {
+const runGit = async (dir: string, args: string[]) => {
   const response = await GitProcess.exec(args, dir);
   if (response.exitCode !== 0) {
     throw new Error(response.stderr.trim());
@@ -91,11 +111,15 @@ const runGit = async (dir, args) => {
   return response.stdout.trim();
 };
 
-const getCommonAncestor = async (dir, point1, point2) => {
+const getCommonAncestor = async (
+  dir: string,
+  point1: string,
+  point2: string
+) => {
   return runGit(dir, ['merge-base', point1, point2]);
 };
 
-const getNoteFromClerk = async (ghKey) => {
+const getNoteFromClerk = async (ghKey: GHKey) => {
   const comments = await getComments(ghKey);
   if (!comments || !comments.data) return;
 
@@ -105,28 +129,29 @@ const getNoteFromClerk = async (ghKey) => {
   const QUOTE_LEAD = '> ';
 
   for (const comment of comments.data.reverse()) {
-    if (comment.user.login !== CLERK_LOGIN) {
+    if (comment.user?.login !== CLERK_LOGIN) {
       continue;
     }
     if (comment.body === CLERK_NO_NOTES) {
       return NO_NOTES;
     }
-    if (comment.body.startsWith(PERSIST_LEAD)) {
+    if (comment.body?.startsWith(PERSIST_LEAD)) {
       let lines = comment.body
-        .slice(PERSIST_LEAD.length).trim() // remove PERSIST_LEAD
+        .slice(PERSIST_LEAD.length)
+        .trim() // remove PERSIST_LEAD
         .split(/\r?\n/) // split into lines
-        .map(line => line.trim())
-        .map(line => line.replace('&lt;', '<'))
-        .map(line => line.replace('&gt;', '>'))
-        .filter(line => line.startsWith(QUOTE_LEAD)) // notes are quoted
-        .map(line => line.slice(QUOTE_LEAD.length)); // unquote the lines
+        .map((line) => line.trim())
+        .map((line) => line.replace('&lt;', '<'))
+        .map((line) => line.replace('&gt;', '>'))
+        .filter((line) => line.startsWith(QUOTE_LEAD)) // notes are quoted
+        .map((line) => line.slice(QUOTE_LEAD.length)); // unquote the lines
 
       const firstLine = lines.shift();
       // indent anything after the first line to ensure that
       // multiline notes with their own sub-lists don't get
       // parsed in the markdown as part of the top-level list
       // (example: https://github.com/electron/electron/pull/25216)
-      lines = lines.map(line => '  ' + line);
+      lines = lines.map((line) => '  ' + line);
       return [firstLine, ...lines]
         .join('\n') // join the lines
         .trim();
@@ -146,7 +171,7 @@ const getNoteFromClerk = async (ghKey) => {
  * line starting with 'BREAKING CHANGE' in body -- sets isBreakingChange
  * 'Backport of #99999' -- sets pr
  */
-const parseCommitMessage = (commitMessage, commit) => {
+const parseCommitMessage = (commitMessage: string, commit: Commit) => {
   const { owner, repo } = commit;
 
   // split commitMessage into subject & body
@@ -180,23 +205,32 @@ const parseCommitMessage = (commitMessage, commit) => {
   }
 
   // Check for a comment that indicates a PR
-  const backportPattern = /(?:^|\n)(?:manual |manually )?backport.*(?:#(\d+)|\/pull\/(\d+))/im;
+  const backportPattern =
+    /(?:^|\n)(?:manual |manually )?backport.*(?:#(\d+)|\/pull\/(\d+))/im;
   if ((match = commitMessage.match(backportPattern))) {
     // This might be the first or second capture group depending on if it's a link or not.
-    const backportNumber = match[1] ? parseInt(match[1], 10) : parseInt(match[2], 10);
+    const backportNumber = match[1]
+      ? parseInt(match[1], 10)
+      : parseInt(match[2], 10);
     commit.prKeys.add(new GHKey(owner, repo, backportNumber));
   }
 
   // https://help.github.com/articles/closing-issues-using-keywords/
-  if (body.match(/\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved|for)\s#(\d+)\b/i)) {
+  if (
+    body.match(
+      /\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved|for)\s#(\d+)\b/i
+    )
+  ) {
     commit.semanticType = commit.semanticType || 'fix';
   }
 
   // https://www.conventionalcommits.org/en
-  if (commitMessage
-    .split(/\r?\n/) // split into lines
-    .map(line => line.trim())
-    .some(line => line.startsWith('BREAKING CHANGE'))) {
+  if (
+    commitMessage
+      .split(/\r?\n/) // split into lines
+      .map((line) => line.trim())
+      .some((line) => line.startsWith('BREAKING CHANGE'))
+  ) {
     commit.isBreakingChange = true;
   }
 
@@ -209,76 +243,109 @@ const parseCommitMessage = (commitMessage, commit) => {
   return commit;
 };
 
-const parsePullText = (pull, commit) => parseCommitMessage(`${pull.data.title}\n\n${pull.data.body}`, commit);
+const parsePullText = (pull: MinimalPR, commit: Commit) =>
+  parseCommitMessage(`${pull.title}\n\n${pull.body}`, commit);
 
-const getLocalCommitHashes = async (dir, ref) => {
+const getLocalCommitHashes = async (dir: string, ref: string) => {
   const args = ['log', '--format=%H', ref];
   return (await runGit(dir, args))
     .split(/\r?\n/) // split into lines
-    .map(hash => hash.trim());
+    .map((hash) => hash.trim());
 };
 
 // return an array of Commits
-const getLocalCommits = async (module, point1, point2) => {
+const getLocalCommits = async (
+  module: LocalRepo,
+  point1: string,
+  point2: string
+) => {
   const { owner, repo, dir } = module;
 
   const fieldSep = ',';
   const format = ['%H', '%s'].join(fieldSep);
-  const args = ['log', '--cherry-pick', '--right-only', '--first-parent', `--format=${format}`, `${point1}..${point2}`];
+  const args = [
+    'log',
+    '--cherry-pick',
+    '--right-only',
+    '--first-parent',
+    `--format=${format}`,
+    `${point1}..${point2}`
+  ];
   const logs = (await runGit(dir, args))
     .split(/\r?\n/) // split into lines
-    .map(field => field.trim());
+    .map((field) => field.trim());
 
   const commits = [];
   for (const log of logs) {
     if (!log) {
       continue;
     }
-    const [hash, subject] = log.split(fieldSep, 2).map(field => field.trim());
+    const [hash, subject] = log.split(fieldSep, 2).map((field) => field.trim());
     commits.push(parseCommitMessage(subject, new Commit(hash, owner, repo)));
   }
   return commits;
 };
 
-const checkCache = async (name, operation) => {
-  const filename = path.resolve(getCacheDir(), name);
-  if (fs.existsSync(filename)) {
-    return JSON.parse(fs.readFileSync(filename, 'utf8'));
+const checkCache = async <T>(
+  name: string,
+  operation: () => Promise<T>
+): Promise<T> => {
+  const filename = _resolve(getCacheDir(), name);
+  if (existsSync(filename)) {
+    return JSON.parse(readFileSync(filename, 'utf8'));
   }
   process.stdout.write('.');
   const response = await operation();
   if (response) {
-    fs.writeFileSync(filename, JSON.stringify(response));
+    writeFileSync(filename, JSON.stringify(response));
   }
   return response;
 };
 
 // helper function to add some resiliency to volatile GH api endpoints
-async function runRetryable (fn, maxRetries) {
-  let lastError;
+async function runRetryable<T> (
+  fn: () => Promise<T>,
+  maxRetries: number
+): Promise<T | null> {
+  let lastError: Error & { status?: number };
   for (let i = 0; i < maxRetries; i++) {
     try {
       return await fn();
     } catch (error) {
-      await new Promise(resolve => setTimeout(resolve, CHECK_INTERVAL));
-      lastError = error;
+      await new Promise((resolve) => setTimeout(resolve, CHECK_INTERVAL));
+      lastError = error as any;
     }
   }
   // Silently eat 404s.
   // Silently eat 422s, which come from "No commit found for SHA"
-  if (lastError.status !== 404 && lastError.status !== 422) throw lastError;
+  // eslint-disable-next-line no-throw-literal
+  if (lastError!.status !== 404 && lastError!.status !== 422) throw lastError!;
+
+  return null;
 }
 
-const getPullCacheFilename = ghKey => `${ghKey.owner}-${ghKey.repo}-pull-${ghKey.number}`;
+const getPullCacheFilename = (ghKey: GHKey) =>
+  `${ghKey.owner}-${ghKey.repo}-pull-${ghKey.number}`;
 
-const getCommitPulls = async (owner, repo, hash) => {
+const getCommitPulls = async (owner: string, repo: string, hash: string) => {
   const name = `${owner}-${repo}-commit-${hash}`;
-  const retryableFunc = () => octokit.repos.listPullRequestsAssociatedWithCommit({ owner, repo, commit_sha: hash });
-  let ret = await checkCache(name, () => runRetryable(retryableFunc, MAX_FAIL_COUNT));
+  const retryableFunc = async () => {
+    const { data } = await octokit.repos.listPullRequestsAssociatedWithCommit({
+      owner,
+      repo,
+      commit_sha: hash
+    });
+    return {
+      data
+    };
+  };
+  let ret = await checkCache(name, () =>
+    runRetryable(retryableFunc, MAX_FAIL_COUNT)
+  );
 
   // only merged pulls belong in release notes
   if (ret && ret.data) {
-    ret.data = ret.data.filter(pull => pull.merged_at);
+    ret.data = ret.data.filter((pull) => pull.merged_at);
   }
 
   // cache the pulls
@@ -286,7 +353,7 @@ const getCommitPulls = async (owner, repo, hash) => {
     for (const pull of ret.data) {
       const cachefile = getPullCacheFilename(GHKey.NewFromPull(pull));
       const payload = { ...ret, data: pull };
-      await checkCache(cachefile, () => payload);
+      await checkCache(cachefile, async () => payload);
     }
   }
 
@@ -298,21 +365,39 @@ const getCommitPulls = async (owner, repo, hash) => {
   return ret;
 };
 
-const getPullRequest = async (ghKey) => {
+const getPullRequest = async (ghKey: GHKey) => {
   const { number, owner, repo } = ghKey;
   const name = getPullCacheFilename(ghKey);
-  const retryableFunc = () => octokit.pulls.get({ pull_number: number, owner, repo });
+  const retryableFunc = () =>
+    octokit.pulls.get({ pull_number: number, owner, repo });
   return checkCache(name, () => runRetryable(retryableFunc, MAX_FAIL_COUNT));
 };
 
-const getComments = async (ghKey) => {
+const getComments = async (ghKey: GHKey) => {
   const { number, owner, repo } = ghKey;
   const name = `${owner}-${repo}-issue-${number}-comments`;
-  const retryableFunc = () => octokit.issues.listComments({ issue_number: number, owner, repo, per_page: 100 });
+  const retryableFunc = () =>
+    octokit.issues.listComments({
+      issue_number: number,
+      owner,
+      repo,
+      per_page: 100
+    });
   return checkCache(name, () => runRetryable(retryableFunc, MAX_FAIL_COUNT));
 };
 
-const addRepoToPool = async (pool, repo, from, to) => {
+type LocalRepo = {
+  owner: string;
+  repo: string;
+  dir: string;
+};
+
+const addRepoToPool = async (
+  pool: Pool,
+  repo: LocalRepo,
+  from: string,
+  to: string
+) => {
   const commonAncestor = await getCommonAncestor(repo.dir, from, to);
 
   // mark the old branch's commits as old news
@@ -337,42 +422,59 @@ const addRepoToPool = async (pool, repo, from, to) => {
     for (prKey of commit.prKeys.values()) {
       const pull = await getPullRequest(prKey);
       if (!pull || !pull.data) continue; // couldn't get it
-      pool.pulls[prKey.number] = pull;
-      parsePullText(pull, commit);
+      pool.pulls[prKey.number] = pull.data;
+      parsePullText(pull.data, commit);
     }
   }
 };
 
+type MinimalComment = {
+  user: {
+    login: string;
+  } | null;
+  body?: string;
+};
+
 // @return Map<string,GHKey>
 //   where the key is a branch name (e.g. '7-1-x' or '8-x-y')
 //   and the value is a GHKey to the PR
-async function getMergedTrops (commit, pool) {
+async function getMergedTrops (commit: Commit, pool: Pool) {
   const branches = new Map();
 
   for (const prKey of commit.prKeys.values()) {
     const pull = pool.pulls[prKey.number];
     const mergedBranches = new Set(
-      ((pull && pull.data && pull.data.labels) ? pull.data.labels : [])
-        .map(label => ((label && label.name) ? label.name : '').match(/merged\/([0-9]+-[x0-9]-[xy0-9])/))
-        .filter(match => match)
-        .map(match => match[1])
+      (pull && pull && pull.labels ? pull.labels : [])
+        .map((label) =>
+          (label && label.name ? label.name : '').match(
+            /merged\/([0-9]+-[x0-9]-[xy0-9])/
+          )
+        )
+        .filter((match) => !!match)
+        .map((match) => match[1])
     );
 
     if (mergedBranches.size > 0) {
-      const isTropComment = (comment) => comment && comment.user && comment.user.login === TROP_LOGIN;
-
-      const ghKey = GHKey.NewFromPull(pull.data);
-      const backportRegex = /backported this PR to "(.*)",\s+please check out #(\d+)/;
-      const getBranchNameAndPullKey = (comment) => {
-        const match = ((comment && comment.body) ? comment.body : '').match(backportRegex);
-        return match ? [match[1], new GHKey(ghKey.owner, ghKey.repo, parseInt(match[2]))] : null;
+      const isTropComment = (comment: MinimalComment | null) =>
+        comment && comment.user && comment.user.login === TROP_LOGIN;
+
+      const ghKey = GHKey.NewFromPull(pull);
+      const backportRegex =
+        /backported this PR to "(.*)",\s+please check out #(\d+)/;
+      const getBranchNameAndPullKey = (comment: MinimalComment) => {
+        const match = (comment && comment.body ? comment.body : '').match(
+          backportRegex
+        );
+        return match
+          ? <const>[match[1], new GHKey(ghKey.owner, ghKey.repo, parseInt(match[2]))]
+          : null;
       };
 
       const comments = await getComments(ghKey);
-      ((comments && comments.data) ? comments.data : [])
+      (comments && comments.data ? comments.data : [])
         .filter(isTropComment)
         .map(getBranchNameAndPullKey)
-        .filter(pair => pair)
+        .filter((pair) => !!pair)
         .filter(([branch]) => mergedBranches.has(branch))
         .forEach(([branch, key]) => branches.set(branch, key));
     }
@@ -383,36 +485,48 @@ async function getMergedTrops (commit, pool) {
 
 // @return the shorthand name of the branch that `ref` is on,
 //   e.g. a ref of '10.0.0-beta.1' will return '10-x-y'
-async function getBranchNameOfRef (ref, dir) {
-  return (await runGit(dir, ['branch', '--all', '--contains', ref, '--sort', 'version:refname']))
+async function getBranchNameOfRef (ref: string, dir: string) {
+  const result = await runGit(dir, [
+    'branch',
+    '--all',
+    '--contains',
+    ref,
+    '--sort',
+    'version:refname'
+  ]);
+  return result
     .split(/\r?\n/) // split into lines
-    .shift() // we sorted by refname and want the first result
-    .match(/(?:\s?\*\s){0,1}(.*)/)[1] // if present, remove leading '* ' in case we're currently in that branch
-    .match(/(?:.*\/)?(.*)/)[1] // 'remote/origins/10-x-y' -> '10-x-y'
+    .shift()! // we sorted by refname and want the first result
+    .match(/(?:\s?\*\s){0,1}(.*)/)![1] // if present, remove leading '* ' in case we're currently in that branch
+    .match(/(?:.*\/)?(.*)/)![1] // 'remote/origins/10-x-y' -> '10-x-y'
     .trim();
 }
 
 /***
-****  Main
-***/
+ ****  Main
+ ***/
 
-const getNotes = async (fromRef, toRef, newVersion) => {
+const getNotes = async (fromRef: string, toRef: string, newVersion: string) => {
   const cacheDir = getCacheDir();
-  if (!fs.existsSync(cacheDir)) {
-    fs.mkdirSync(cacheDir);
+  if (!existsSync(cacheDir)) {
+    mkdirSync(cacheDir);
   }
 
   const pool = new Pool();
   const toBranch = await getBranchNameOfRef(toRef, ELECTRON_DIR);
 
-  console.log(`Generating release notes between '${fromRef}' and '${toRef}' for version '${newVersion}' in branch '${toBranch}'`);
+  console.log(
+    `Generating release notes between '${fromRef}' and '${toRef}' for version '${newVersion}' in branch '${toBranch}'`
+  );
 
   // get the electron/electron commits
   const electron = { owner: 'electron', repo: 'electron', dir: ELECTRON_DIR };
   await addRepoToPool(pool, electron, fromRef, toRef);
 
   // remove any old commits
-  pool.commits = pool.commits.filter(commit => !pool.processedHashes.has(commit.hash));
+  pool.commits = pool.commits.filter(
+    (commit) => !pool.processedHashes.has(commit.hash)
+  );
 
   // if a commit _and_ revert occurred in the unprocessed set, skip them both
   for (const commit of pool.commits) {
@@ -421,7 +535,7 @@ const getNotes = async (fromRef, toRef, newVersion) => {
       continue;
     }
 
-    const revert = pool.commits.find(commit => commit.hash === revertHash);
+    const revert = pool.commits.find((commit) => commit.hash === revertHash);
     if (!revert) {
       continue;
     }
@@ -438,15 +552,15 @@ const getNotes = async (fromRef, toRef, newVersion) => {
       if (commit.note) {
         break;
       }
-      commit.note = await getNoteFromClerk(prKey);
+      commit.note = await getNoteFromClerk(prKey) || null;
     }
   }
 
   // remove non-user-facing commits
   pool.commits = pool.commits
-    .filter(commit => commit && commit.note)
-    .filter(commit => commit.note !== NO_NOTES)
-    .filter(commit => commit.note.match(/^[Bb]ump v\d+\.\d+\.\d+/) === null);
+    .filter((commit) => commit && commit.note)
+    .filter((commit) => commit.note !== NO_NOTES)
+    .filter((commit) => commit.note!.match(/^[Bb]ump v\d+\.\d+\.\d+/) === null);
 
   for (const commit of pool.commits) {
     commit.trops = await getMergedTrops(commit, pool);
@@ -455,12 +569,12 @@ const getNotes = async (fromRef, toRef, newVersion) => {
   pool.commits = removeSupercededStackUpdates(pool.commits);
 
   const notes = {
-    breaking: [],
-    docs: [],
-    feat: [],
-    fix: [],
-    other: [],
-    unknown: [],
+    breaking: [] as Commit[],
+    docs: [] as Commit[],
+    feat: [] as Commit[],
+    fix: [] as Commit[],
+    other: [] as Commit[],
+    unknown: [] as Commit[],
     name: newVersion,
     toBranch
   };
@@ -487,11 +601,13 @@ const getNotes = async (fromRef, toRef, newVersion) => {
   return notes;
 };
 
-const compareVersions = (v1, v2) => {
+const compareVersions = (v1: string, v2: string) => {
   const [split1, split2] = [v1.split('.'), v2.split('.')];
 
   if (split1.length !== split2.length) {
-    throw new Error(`Expected version strings to have same number of sections: ${split1} and ${split2}`);
+    throw new Error(
+      `Expected version strings to have same number of sections: ${split1} and ${split2}`
+    );
   }
   for (let i = 0; i < split1.length; i++) {
     const p1 = parseInt(split1[i], 10);
@@ -505,13 +621,13 @@ const compareVersions = (v1, v2) => {
   return 0;
 };
 
-const removeSupercededStackUpdates = (commits) => {
+const removeSupercededStackUpdates = (commits: Commit[]) => {
   const updateRegex = /^Updated ([a-zA-Z.]+) to v?([\d.]+)/;
   const notupdates = [];
 
-  const newest = {};
+  const newest: Record<string, { commit: Commit; version: string }> = Object.create(null);
   for (const commit of commits) {
-    const match = (commit.note || commit.subject).match(updateRegex);
+    const match = (commit.note || commit.subject)?.match(updateRegex);
     if (!match) {
       notupdates.push(commit);
       continue;
@@ -523,48 +639,56 @@ const removeSupercededStackUpdates = (commits) => {
     }
   }
 
-  return [...notupdates, ...Object.values(newest).map(o => o.commit)];
+  return [...notupdates, ...Object.values(newest).map((o) => o.commit)];
 };
 
 /***
-****  Render
-***/
+ ****  Render
+ ***/
 
 // @return the pull request's GitHub URL
-const buildPullURL = ghKey => `https://github.com/${ghKey.owner}/${ghKey.repo}/pull/${ghKey.number}`;
+const buildPullURL = (ghKey: GHKey) =>
+  `https://github.com/${ghKey.owner}/${ghKey.repo}/pull/${ghKey.number}`;
 
-const renderPull = ghKey => `[#${ghKey.number}](${buildPullURL(ghKey)})`;
+const renderPull = (ghKey: GHKey) =>
+  `[#${ghKey.number}](${buildPullURL(ghKey)})`;
 
 // @return the commit's GitHub URL
-const buildCommitURL = commit => `https://github.com/${commit.owner}/${commit.repo}/commit/${commit.hash}`;
+const buildCommitURL = (commit: Commit) =>
+  `https://github.com/${commit.owner}/${commit.repo}/commit/${commit.hash}`;
 
-const renderCommit = commit => `[${commit.hash.slice(0, 8)}](${buildCommitURL(commit)})`;
+const renderCommit = (commit: Commit) =>
+  `[${commit.hash.slice(0, 8)}](${buildCommitURL(commit)})`;
 
 // @return a markdown link to the PR if available; otherwise, the git commit
-function renderLink (commit) {
+function renderLink (commit: Commit) {
   const maybePull = commit.prKeys.values().next();
   return maybePull.value ? renderPull(maybePull.value) : renderCommit(commit);
 }
 
 // @return a terser branch name,
 //   e.g. '7-2-x' -> '7.2' and '8-x-y' -> '8'
-const renderBranchName = name => name.replace(/-[a-zA-Z]/g, '').replace('-', '.');
+const renderBranchName = (name: string) =>
+  name.replace(/-[a-zA-Z]/g, '').replace('-', '.');
 
-const renderTrop = (branch, ghKey) => `[${renderBranchName(branch)}](${buildPullURL(ghKey)})`;
+const renderTrop = (branch: string, ghKey: GHKey) =>
+  `[${renderBranchName(branch)}](${buildPullURL(ghKey)})`;
 
 // @return markdown-formatted links to other branches' trops,
 //   e.g. "(Also in 7.2, 8, 9)"
-function renderTrops (commit, excludeBranch) {
+function renderTrops (commit: Commit, excludeBranch: string) {
   const body = [...commit.trops.entries()]
     .filter(([branch]) => branch !== excludeBranch)
     .sort(([branchA], [branchB]) => parseInt(branchA) - parseInt(branchB)) // sort by semver major
     .map(([branch, key]) => renderTrop(branch, key))
     .join(', ');
-  return body ? `<span style="font-size:small;">(Also in ${body})</span>` : body;
+  return body
+    ? `<span style="font-size:small;">(Also in ${body})</span>`
+    : body;
 }
 
 // @return a slightly cleaned-up human-readable change description
-function renderDescription (commit) {
+function renderDescription (commit: Commit) {
   let note = commit.note || commit.subject || '';
   note = note.trim();
 
@@ -616,21 +740,26 @@ function renderDescription (commit) {
 
 // @return markdown-formatted release note line item,
 //   e.g. '* Fixed a foo. #12345 (Also in 7.2, 8, 9)'
-const renderNote = (commit, excludeBranch) =>
-  `* ${renderDescription(commit)} ${renderLink(commit)} ${renderTrops(commit, excludeBranch)}\n`;
+const renderNote = (commit: Commit, excludeBranch: string) =>
+  `* ${renderDescription(commit)} ${renderLink(commit)} ${renderTrops(
+    commit,
+    excludeBranch
+  )}\n`;
 
-const renderNotes = (notes, unique = false) => {
+const renderNotes = (notes: Awaited<ReturnType<typeof getNotes>>, unique = false) => {
   const rendered = [`# Release Notes for ${notes.name}\n\n`];
 
-  const renderSection = (title, commits, unique) => {
+  const renderSection = (title: string, commits: Commit[], unique: boolean) => {
     if (unique) {
       // omit changes that also landed in other branches
-      commits = commits.filter((commit) => renderTrops(commit, notes.toBranch).length === 0);
+      commits = commits.filter(
+        (commit) => renderTrops(commit, notes.toBranch).length === 0
+      );
     }
     if (commits.length > 0) {
       rendered.push(
         `## ${title}\n\n`,
-        ...(commits.map(commit => renderNote(commit, notes.toBranch)).sort())
+        ...commits.map((commit) => renderNote(commit, notes.toBranch)).sort()
       );
     }
   };
@@ -641,8 +770,12 @@ const renderNotes = (notes, unique = false) => {
   renderSection('Other Changes', notes.other, unique);
 
   if (notes.docs.length) {
-    const docs = notes.docs.map(commit => renderLink(commit)).sort();
-    rendered.push('## Documentation\n\n', ` * Documentation changes: ${docs.join(', ')}\n`, '\n');
+    const docs = notes.docs.map((commit) => renderLink(commit)).sort();
+    rendered.push(
+      '## Documentation\n\n',
+      ` * Documentation changes: ${docs.join(', ')}\n`,
+      '\n'
+    );
   }
 
   renderSection('Unknown', notes.unknown, unique);
@@ -651,10 +784,8 @@ const renderNotes = (notes, unique = false) => {
 };
 
 /***
-****  Module
-***/
+ ****  Module
+ ***/
 
-module.exports = {
-  get: getNotes,
-  render: renderNotes
-};
+export const get = getNotes;
+export const render = renderNotes;

+ 71 - 44
script/release/prepare-release.js → script/release/prepare-release.ts

@@ -1,25 +1,45 @@
 #!/usr/bin/env node
 
-if (!process.env.CI) require('dotenv-safe').load();
-const args = require('minimist')(process.argv.slice(2), {
-  boolean: ['automaticRelease', 'notesOnly', 'stable']
+import { Octokit } from '@octokit/rest';
+import * as chalk from 'chalk';
+import { GitProcess } from 'dugite';
+import { execSync } from 'node:child_process';
+import { join } from 'node:path';
+import { createInterface } from 'node:readline';
+import { parseArgs } from 'node:util';
+
+import ciReleaseBuild from './ci-release-build';
+import releaseNotesGenerator from './notes';
+import { getCurrentBranch, ELECTRON_DIR } from '../lib/utils.js';
+import { createGitHubTokenStrategy } from './github-token';
+import { ELECTRON_REPO, ElectronReleaseRepo, NIGHTLY_REPO } from './types';
+
+const { values: { notesOnly, dryRun: dryRunArg, stable: isStableArg, branch: branchArg, automaticRelease }, positionals } = parseArgs({
+  options: {
+    notesOnly: {
+      type: 'boolean'
+    },
+    dryRun: {
+      type: 'boolean'
+    },
+    stable: {
+      type: 'boolean'
+    },
+    branch: {
+      type: 'string'
+    },
+    automaticRelease: {
+      type: 'boolean'
+    }
+  },
+  allowPositionals: true
 });
-const ciReleaseBuild = require('./ci-release-build');
-const { Octokit } = require('@octokit/rest');
-const { execSync } = require('node:child_process');
-const { GitProcess } = require('dugite');
-const chalk = require('chalk');
-
-const path = require('node:path');
-const readline = require('node:readline');
-const releaseNotesGenerator = require('./notes/index.js');
-const { getCurrentBranch, ELECTRON_DIR } = require('../lib/utils.js');
-const { createGitHubTokenStrategy } = require('./github-token');
-const bumpType = args._[0];
+
+const bumpType = positionals[0];
 const targetRepo = getRepo();
 
-function getRepo () {
-  return bumpType === 'nightly' ? 'nightlies' : 'electron';
+function getRepo (): ElectronReleaseRepo {
+  return bumpType === 'nightly' ? NIGHTLY_REPO : ELECTRON_REPO;
 }
 
 const octokit = new Octokit({
@@ -29,24 +49,29 @@ const octokit = new Octokit({
 const pass = chalk.green('✓');
 const fail = chalk.red('✗');
 
-if (!bumpType && !args.notesOnly) {
+if (!bumpType && !notesOnly) {
   console.log('Usage: prepare-release [stable | minor | beta | alpha | nightly]' +
      ' (--stable) (--notesOnly) (--automaticRelease) (--branch)');
   process.exit(1);
 }
 
-async function getNewVersion (dryRun) {
-  if (!dryRun) {
+enum DryRunMode {
+  DRY_RUN,
+  REAL_RUN,
+}
+
+async function getNewVersion (dryRunMode: DryRunMode) {
+  if (dryRunMode === DryRunMode.REAL_RUN) {
     console.log(`Bumping for new "${bumpType}" version.`);
   }
-  const bumpScript = path.join(__dirname, 'version-bumper.js');
+  const bumpScript = join(__dirname, 'version-bumper.js');
   const scriptArgs = ['node', bumpScript, `--bump=${bumpType}`];
-  if (dryRun) scriptArgs.push('--dryRun');
+  if (dryRunMode === DryRunMode.DRY_RUN) scriptArgs.push('--dryRun');
   try {
-    let bumpVersion = execSync(scriptArgs.join(' '), { encoding: 'UTF-8' });
+    let bumpVersion = execSync(scriptArgs.join(' '), { encoding: 'utf-8' });
     bumpVersion = bumpVersion.substr(bumpVersion.indexOf(':') + 1).trim();
     const newVersion = `v${bumpVersion}`;
-    if (!dryRun) {
+    if (dryRunMode === DryRunMode.REAL_RUN) {
       console.log(`${pass} Successfully bumped version to ${newVersion}`);
     }
     return newVersion;
@@ -56,7 +81,7 @@ async function getNewVersion (dryRun) {
   }
 }
 
-async function getReleaseNotes (currentBranch, newVersion) {
+async function getReleaseNotes (currentBranch: string, newVersion: string) {
   if (bumpType === 'nightly') {
     return { text: 'Nightlies do not get release notes, please compare tags for info.' };
   }
@@ -68,8 +93,8 @@ async function getReleaseNotes (currentBranch, newVersion) {
   return releaseNotes;
 }
 
-async function createRelease (branchToTarget, isBeta) {
-  const newVersion = await getNewVersion();
+async function createRelease (branchToTarget: string, isPreRelease: boolean) {
+  const newVersion = await getNewVersion(DryRunMode.REAL_RUN);
   const releaseNotes = await getReleaseNotes(branchToTarget, newVersion);
   await tagRelease(newVersion);
 
@@ -79,6 +104,7 @@ async function createRelease (branchToTarget, isBeta) {
     repo: targetRepo
   }).catch(err => {
     console.log(`${fail} Could not get releases. Error was: `, err);
+    throw err;
   });
 
   const drafts = releases.data.filter(release => release.draft &&
@@ -92,7 +118,7 @@ async function createRelease (branchToTarget, isBeta) {
 
   let releaseBody;
   let releaseIsPrelease = false;
-  if (isBeta) {
+  if (isPreRelease) {
     if (newVersion.indexOf('nightly') > 0) {
       releaseBody = 'Note: This is a nightly release.  Please file new issues ' +
         'for any bugs you find in it.\n \n This release is published to npm ' +
@@ -132,7 +158,7 @@ async function createRelease (branchToTarget, isBeta) {
   console.log(`${pass} Draft release for ${newVersion} successful.`);
 }
 
-async function pushRelease (branch) {
+async function pushRelease (branch: string) {
   const pushDetails = await GitProcess.exec(['push', 'origin', `HEAD:${branch}`, '--follow-tags'], ELECTRON_DIR);
   if (pushDetails.exitCode === 0) {
     console.log(`${pass} Successfully pushed the release.  Wait for ` +
@@ -143,14 +169,15 @@ async function pushRelease (branch) {
   }
 }
 
-async function runReleaseBuilds (branch, newVersion) {
+async function runReleaseBuilds (branch: string, newVersion: string) {
   await ciReleaseBuild(branch, {
+    ci: undefined,
     ghRelease: true,
     newVersion
   });
 }
 
-async function tagRelease (version) {
+async function tagRelease (version: string) {
   console.log(`Tagging release ${version}.`);
   const checkoutDetails = await GitProcess.exec(['tag', '-a', '-m', version, version], ELECTRON_DIR);
   if (checkoutDetails.exitCode === 0) {
@@ -163,9 +190,9 @@ async function tagRelease (version) {
 }
 
 async function verifyNewVersion () {
-  const newVersion = await getNewVersion(true);
+  const newVersion = await getNewVersion(DryRunMode.DRY_RUN);
   let response;
-  if (args.automaticRelease) {
+  if (automaticRelease) {
     response = 'y';
   } else {
     response = await promptForVersion(newVersion);
@@ -180,13 +207,13 @@ async function verifyNewVersion () {
   return newVersion;
 }
 
-async function promptForVersion (version) {
-  return new Promise(resolve => {
-    const rl = readline.createInterface({
+async function promptForVersion (version: string) {
+  return new Promise<string>(resolve => {
+    const rl = createInterface({
       input: process.stdin,
       output: process.stdout
     });
-    rl.question(`Do you want to create the release ${version.green} (y/N)? `, (answer) => {
+    rl.question(`Do you want to create the release ${chalk.green(version)} (y/N)? `, (answer) => {
       rl.close();
       resolve(answer);
     });
@@ -200,21 +227,21 @@ async function changesToRelease () {
   return !lastCommitWasRelease.test(lastCommit.stdout);
 }
 
-async function prepareRelease (isBeta, notesOnly) {
-  if (args.dryRun) {
-    const newVersion = await getNewVersion(true);
+async function prepareRelease (isPreRelease: boolean, dryRunMode: DryRunMode) {
+  if (dryRunMode === DryRunMode.DRY_RUN) {
+    const newVersion = await getNewVersion(DryRunMode.DRY_RUN);
     console.log(newVersion);
   } else {
-    const currentBranch = (args.branch) ? args.branch : await getCurrentBranch(ELECTRON_DIR);
+    const currentBranch = branchArg || await getCurrentBranch(ELECTRON_DIR);
     if (notesOnly) {
-      const newVersion = await getNewVersion(true);
+      const newVersion = await getNewVersion(DryRunMode.DRY_RUN);
       const releaseNotes = await getReleaseNotes(currentBranch, newVersion);
       console.log(`Draft release notes are: \n${releaseNotes.text}`);
     } else {
       const changes = await changesToRelease();
       if (changes) {
         const newVersion = await verifyNewVersion();
-        await createRelease(currentBranch, isBeta);
+        await createRelease(currentBranch, isPreRelease);
         await pushRelease(currentBranch);
         await runReleaseBuilds(currentBranch, newVersion);
       } else {
@@ -225,7 +252,7 @@ async function prepareRelease (isBeta, notesOnly) {
   }
 }
 
-prepareRelease(!args.stable, args.notesOnly)
+prepareRelease(!isStableArg, dryRunArg ? DryRunMode.DRY_RUN : DryRunMode.REAL_RUN)
   .catch((err) => {
     console.error(err);
     process.exit(1);

+ 20 - 18
script/release/publish-to-npm.js → script/release/publish-to-npm.ts

@@ -1,23 +1,25 @@
-const temp = require('temp');
-const fs = require('node:fs');
-const path = require('node:path');
-const childProcess = require('node:child_process');
-const semver = require('semver');
+import { Octokit } from '@octokit/rest';
+import * as childProcess from 'node:child_process';
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as semver from 'semver';
+import * as temp from 'temp';
 
-const { getCurrentBranch, ELECTRON_DIR } = require('../lib/utils');
-const { getElectronVersion } = require('../lib/get-version');
-const rootPackageJson = require('../../package.json');
+import { getCurrentBranch, ELECTRON_DIR } from '../lib/utils';
+import { getElectronVersion } from '../lib/get-version';
 
-const { Octokit } = require('@octokit/rest');
-const { getAssetContents } = require('./get-asset');
-const { createGitHubTokenStrategy } = require('./github-token');
+import { getAssetContents } from './get-asset';
+import { createGitHubTokenStrategy } from './github-token';
+import { ELECTRON_ORG, ELECTRON_REPO, ElectronReleaseRepo, NIGHTLY_REPO } from './types';
+
+const rootPackageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf-8'));
 
 if (!process.env.ELECTRON_NPM_OTP) {
   console.error('Please set ELECTRON_NPM_OTP');
   process.exit(1);
 }
 
-let tempDir;
+let tempDir: string;
 temp.track(); // track and cleanup files at exit
 
 const files = [
@@ -49,11 +51,11 @@ const octokit = new Octokit({
   authStrategy: createGitHubTokenStrategy(targetRepo)
 });
 
-function getRepo () {
-  return isNightlyElectronVersion ? 'nightlies' : 'electron';
+function getRepo (): ElectronReleaseRepo {
+  return isNightlyElectronVersion ? NIGHTLY_REPO : ELECTRON_REPO;
 }
 
-new Promise((resolve, reject) => {
+new Promise<string>((resolve, reject) => {
   temp.mkdir('electron-npm', (err, dirPath) => {
     if (err) {
       reject(err);
@@ -84,7 +86,7 @@ new Promise((resolve, reject) => {
     );
 
     return octokit.repos.listReleases({
-      owner: 'electron',
+      owner: ELECTRON_ORG,
       repo: targetRepo
     });
   })
@@ -124,7 +126,7 @@ new Promise((resolve, reject) => {
       checksumsAsset.id
     );
 
-    const checksumsObject = {};
+    const checksumsObject: Record<string, string> = Object.create(null);
     for (const line of checksumsContent.trim().split('\n')) {
       const [checksum, file] = line.split(' *');
       checksumsObject[file] = checksum;
@@ -203,7 +205,7 @@ new Promise((resolve, reject) => {
   })
   .then(() => {
     const currentTags = JSON.parse(childProcess.execSync('npm show electron dist-tags --json').toString());
-    const parsedLocalVersion = semver.parse(currentElectronVersion);
+    const parsedLocalVersion = semver.parse(currentElectronVersion)!;
     if (rootPackageJson.name === 'electron') {
       // We should only customly add dist tags for non-nightly releases where the package name is still
       // "electron"

+ 41 - 21
script/release/release-artifact-cleanup.js → script/release/release-artifact-cleanup.ts

@@ -1,26 +1,42 @@
 #!/usr/bin/env node
 
-if (!process.env.CI) require('dotenv-safe').load();
-const args = require('minimist')(process.argv.slice(2), {
-  string: ['tag', 'releaseID'],
-  default: { releaseID: '' }
+import { Octokit } from '@octokit/rest';
+import * as chalk from 'chalk';
+import { parseArgs } from 'node:util';
+
+import { createGitHubTokenStrategy } from './github-token';
+import { ELECTRON_ORG, ELECTRON_REPO, ElectronReleaseRepo, NIGHTLY_REPO } from './types';
+
+const { values: { tag: _tag, releaseID } } = parseArgs({
+  options: {
+    tag: {
+      type: 'string'
+    },
+    releaseID: {
+      type: 'string',
+      default: ''
+    }
+  }
 });
-const { Octokit } = require('@octokit/rest');
-const chalk = require('chalk');
 
-const { createGitHubTokenStrategy } = require('./github-token');
+if (!_tag) {
+  console.error('Missing --tag argument');
+  process.exit(1);
+}
+
+const tag = _tag;
 
 const pass = chalk.green('✓');
 const fail = chalk.red('✗');
 
-async function deleteDraft (releaseId, targetRepo) {
+async function deleteDraft (releaseId: string, targetRepo: ElectronReleaseRepo) {
   const octokit = new Octokit({
     authStrategy: createGitHubTokenStrategy(targetRepo)
   });
 
   try {
     const result = await octokit.repos.getRelease({
-      owner: 'electron',
+      owner: ELECTRON_ORG,
       repo: targetRepo,
       release_id: parseInt(releaseId, 10)
     });
@@ -29,7 +45,7 @@ async function deleteDraft (releaseId, targetRepo) {
       return false;
     } else {
       await octokit.repos.deleteRelease({
-        owner: 'electron',
+        owner: ELECTRON_ORG,
         repo: targetRepo,
         release_id: result.data.id
       });
@@ -42,14 +58,14 @@ async function deleteDraft (releaseId, targetRepo) {
   }
 }
 
-async function deleteTag (tag, targetRepo) {
+async function deleteTag (tag: string, targetRepo: ElectronReleaseRepo) {
   const octokit = new Octokit({
     authStrategy: createGitHubTokenStrategy(targetRepo)
   });
 
   try {
     await octokit.git.deleteRef({
-      owner: 'electron',
+      owner: ELECTRON_ORG,
       repo: targetRepo,
       ref: `tags/${tag}`
     });
@@ -60,31 +76,35 @@ async function deleteTag (tag, targetRepo) {
 }
 
 async function cleanReleaseArtifacts () {
-  const releaseId = args.releaseID.length > 0 ? args.releaseID : null;
-  const isNightly = args.tag.includes('nightly');
+  const releaseId = releaseID && releaseID.length > 0 ? releaseID : null;
+  const isNightly = tag.includes('nightly');
 
   if (releaseId) {
     if (isNightly) {
-      await deleteDraft(releaseId, 'nightlies');
+      await deleteDraft(releaseId, NIGHTLY_REPO);
 
       // We only need to delete the Electron tag since the
       // nightly tag is only created at publish-time.
-      await deleteTag(args.tag, 'electron');
+      await deleteTag(tag, ELECTRON_REPO);
     } else {
-      const deletedElectronDraft = await deleteDraft(releaseId, 'electron');
+      const deletedElectronDraft = await deleteDraft(releaseId, ELECTRON_REPO);
       // don't delete tag unless draft deleted successfully
       if (deletedElectronDraft) {
-        await deleteTag(args.tag, 'electron');
+        await deleteTag(tag, ELECTRON_REPO);
       }
     }
   } else {
     await Promise.all([
-      deleteTag(args.tag, 'electron'),
-      deleteTag(args.tag, 'nightlies')
+      deleteTag(tag, ELECTRON_REPO),
+      deleteTag(tag, NIGHTLY_REPO)
     ]);
   }
 
   console.log(`${pass} failed release artifact cleanup complete`);
 }
 
-cleanReleaseArtifacts();
+cleanReleaseArtifacts()
+  .catch((err) => {
+    console.error(err);
+    process.exit(1);
+  });

+ 286 - 156
script/release/release.js → script/release/release.ts

@@ -1,36 +1,31 @@
 #!/usr/bin/env node
 
-if (!process.env.CI) require('dotenv-safe').load();
-
-const chalk = require('chalk');
-const args = require('minimist')(process.argv.slice(2), {
-  boolean: [
-    'validateRelease',
-    'verboseNugget'
-  ],
-  default: { verboseNugget: false }
-});
-const fs = require('node:fs');
-const { execSync } = require('node:child_process');
-const got = require('got');
-const path = require('node:path');
-const semver = require('semver');
-const temp = require('temp').track();
-const { BlobServiceClient } = require('@azure/storage-blob');
-const { Octokit } = require('@octokit/rest');
+import { BlobServiceClient } from '@azure/storage-blob';
+import { Octokit } from '@octokit/rest';
+import * as chalk from 'chalk';
+import got from 'got';
+import { execSync, ExecSyncOptions } from 'node:child_process';
+import { statSync, createReadStream, writeFileSync, close } from 'node:fs';
+import { join } from 'node:path';
+import { gte } from 'semver';
+import { track as trackTemp } from 'temp';
+
+import { ELECTRON_DIR } from '../lib/utils';
+import { getElectronVersion } from '../lib/get-version';
+import { getUrlHash } from './get-url-hash';
+import { createGitHubTokenStrategy } from './github-token';
+import { ELECTRON_REPO, ElectronReleaseRepo, NIGHTLY_REPO } from './types';
+import { parseArgs } from 'node:util';
+
+const temp = trackTemp();
 
 const pass = chalk.green('✓');
 const fail = chalk.red('✗');
 
-const { ELECTRON_DIR } = require('../lib/utils');
-const { getElectronVersion } = require('../lib/get-version');
-const getUrlHash = require('./get-url-hash');
-const { createGitHubTokenStrategy } = require('./github-token');
-
 const pkgVersion = `v${getElectronVersion()}`;
 
-function getRepo () {
-  return pkgVersion.indexOf('nightly') > 0 ? 'nightlies' : 'electron';
+function getRepo (): ElectronReleaseRepo {
+  return pkgVersion.indexOf('nightly') > 0 ? NIGHTLY_REPO : ELECTRON_REPO;
 }
 
 const targetRepo = getRepo();
@@ -40,14 +35,17 @@ const octokit = new Octokit({
   authStrategy: createGitHubTokenStrategy(targetRepo)
 });
 
-async function getDraftRelease (version, skipValidation) {
+async function getDraftRelease (
+  version?: string,
+  skipValidation: boolean = false
+) {
   const releaseInfo = await octokit.repos.listReleases({
     owner: 'electron',
     repo: targetRepo
   });
 
   const versionToCheck = version || pkgVersion;
-  const drafts = releaseInfo.data.filter(release => {
+  const drafts = releaseInfo.data.filter((release) => {
     return release.tag_name === versionToCheck && release.draft === true;
   });
 
@@ -58,38 +56,66 @@ async function getDraftRelease (version, skipValidation) {
     if (versionToCheck.includes('beta')) {
       check(draft.prerelease, 'draft is a prerelease');
     }
-    check(draft.body.length > 50 && !draft.body.includes('(placeholder)'), 'draft has release notes');
-    check((failureCount === 0), 'Draft release looks good to go.', true);
+    check(
+      !!draft.body &&
+        draft.body.length > 50 &&
+        !draft.body.includes('(placeholder)'),
+      'draft has release notes'
+    );
+    check(failureCount === 0, 'Draft release looks good to go.', true);
   }
   return draft;
 }
 
-async function validateReleaseAssets (release, validatingRelease) {
-  const requiredAssets = assetsForVersion(release.tag_name, validatingRelease).sort();
-  const extantAssets = release.assets.map(asset => asset.name).sort();
-  const downloadUrls = release.assets.map(asset => ({ url: asset.browser_download_url, file: asset.name })).sort((a, b) => a.file.localeCompare(b.file));
+type MinimalRelease = {
+  id: number;
+  tag_name: string;
+  draft: boolean;
+  prerelease: boolean;
+  assets: {
+    name: string;
+    browser_download_url: string;
+    id: number;
+  }[];
+};
+
+async function validateReleaseAssets (
+  release: MinimalRelease,
+  validatingRelease: boolean = false
+) {
+  const requiredAssets = assetsForVersion(
+    release.tag_name,
+    validatingRelease
+  ).sort();
+  const extantAssets = release.assets.map((asset) => asset.name).sort();
+  const downloadUrls = release.assets
+    .map((asset) => ({ url: asset.browser_download_url, file: asset.name }))
+    .sort((a, b) => a.file.localeCompare(b.file));
 
   failureCount = 0;
   for (const asset of requiredAssets) {
     check(extantAssets.includes(asset), asset);
   }
-  check((failureCount === 0), 'All required GitHub assets exist for release', true);
+  check(
+    failureCount === 0,
+    'All required GitHub assets exist for release',
+    true
+  );
 
   if (!validatingRelease || !release.draft) {
     if (release.draft) {
       await verifyDraftGitHubReleaseAssets(release);
     } else {
-      await verifyShasumsForRemoteFiles(downloadUrls)
-        .catch(err => {
-          console.error(`${fail} error verifyingShasums`, err);
-        });
+      await verifyShasumsForRemoteFiles(downloadUrls).catch((err) => {
+        console.error(`${fail} error verifyingShasums`, err);
+      });
     }
     const azRemoteFiles = azRemoteFilesForVersion(release.tag_name);
     await verifyShasumsForRemoteFiles(azRemoteFiles, true);
   }
 }
 
-function check (condition, statement, exitIfFail = false) {
+function check (condition: boolean, statement: string, exitIfFail = false) {
   if (condition) {
     console.log(`${pass} ${statement}`);
   } else {
@@ -99,7 +125,7 @@ function check (condition, statement, exitIfFail = false) {
   }
 }
 
-function assetsForVersion (version, validatingRelease) {
+function assetsForVersion (version: string, validatingRelease: boolean) {
   const patterns = [
     `chromedriver-${version}-darwin-x64.zip`,
     `chromedriver-${version}-darwin-arm64.zip`,
@@ -181,7 +207,7 @@ function assetsForVersion (version, validatingRelease) {
   return patterns;
 }
 
-const cloudStoreFilePaths = (version) => [
+const cloudStoreFilePaths = (version: string) => [
   `iojs-${version}-headers.tar.gz`,
   `iojs-${version}.tar.gz`,
   `node-${version}.tar.gz`,
@@ -198,7 +224,7 @@ const cloudStoreFilePaths = (version) => [
   'SHASUMS256.txt'
 ];
 
-function azRemoteFilesForVersion (version) {
+function azRemoteFilesForVersion (version: string) {
   const azCDN = 'https://artifacts.electronjs.org/headers/';
   const versionPrefix = `${azCDN}dist/${version}/`;
   return cloudStoreFilePaths(version).map((filePath) => ({
@@ -207,10 +233,10 @@ function azRemoteFilesForVersion (version) {
   }));
 }
 
-function runScript (scriptName, scriptArgs, cwd) {
+function runScript (scriptName: string, scriptArgs: string[], cwd?: string) {
   const scriptCommand = `${scriptName} ${scriptArgs.join(' ')}`;
-  const scriptOptions = {
-    encoding: 'UTF-8'
+  const scriptOptions: ExecSyncOptions = {
+    encoding: 'utf-8'
   };
   if (cwd) scriptOptions.cwd = cwd;
   try {
@@ -223,29 +249,48 @@ function runScript (scriptName, scriptArgs, cwd) {
 
 function uploadNodeShasums () {
   console.log('Uploading Node SHASUMS file to artifacts.electronjs.org.');
-  const scriptPath = path.join(ELECTRON_DIR, 'script', 'release', 'uploaders', 'upload-node-checksums.py');
+  const scriptPath = join(
+    ELECTRON_DIR,
+    'script',
+    'release',
+    'uploaders',
+    'upload-node-checksums.py'
+  );
   runScript(scriptPath, ['-v', pkgVersion]);
-  console.log(`${pass} Done uploading Node SHASUMS file to artifacts.electronjs.org.`);
+  console.log(
+    `${pass} Done uploading Node SHASUMS file to artifacts.electronjs.org.`
+  );
 }
 
 function uploadIndexJson () {
   console.log('Uploading index.json to artifacts.electronjs.org.');
-  const scriptPath = path.join(ELECTRON_DIR, 'script', 'release', 'uploaders', 'upload-index-json.py');
+  const scriptPath = join(
+    ELECTRON_DIR,
+    'script',
+    'release',
+    'uploaders',
+    'upload-index-json.py'
+  );
   runScript(scriptPath, [pkgVersion]);
   console.log(`${pass} Done uploading index.json to artifacts.electronjs.org.`);
 }
 
-async function mergeShasums (pkgVersion) {
+async function mergeShasums (pkgVersion: string) {
   // Download individual checksum files for Electron zip files from artifact storage,
   // concatenate them, and upload to GitHub.
 
   const connectionString = process.env.ELECTRON_ARTIFACTS_BLOB_STORAGE;
   if (!connectionString) {
-    throw new Error('Please set the $ELECTRON_ARTIFACTS_BLOB_STORAGE environment variable');
+    throw new Error(
+      'Please set the $ELECTRON_ARTIFACTS_BLOB_STORAGE environment variable'
+    );
   }
 
-  const blobServiceClient = BlobServiceClient.fromConnectionString(connectionString);
-  const containerClient = blobServiceClient.getContainerClient('checksums-scratchpad');
+  const blobServiceClient =
+    BlobServiceClient.fromConnectionString(connectionString);
+  const containerClient = blobServiceClient.getContainerClient(
+    'checksums-scratchpad'
+  );
   const blobsIter = containerClient.listBlobsFlat({
     prefix: `${pkgVersion}/`
   });
@@ -260,19 +305,25 @@ async function mergeShasums (pkgVersion) {
   return shasums.join('\n');
 }
 
-async function createReleaseShasums (release) {
+async function createReleaseShasums (release: MinimalRelease) {
   const fileName = 'SHASUMS256.txt';
-  const existingAssets = release.assets.filter(asset => asset.name === fileName);
+  const existingAssets = release.assets.filter(
+    (asset) => asset.name === fileName
+  );
   if (existingAssets.length > 0) {
-    console.log(`${fileName} already exists on GitHub; deleting before creating new file.`);
-    await octokit.repos.deleteReleaseAsset({
-      owner: 'electron',
-      repo: targetRepo,
-      asset_id: existingAssets[0].id
-    }).catch(err => {
-      console.error(`${fail} Error deleting ${fileName} on GitHub:`, err);
-      process.exit(1);
-    });
+    console.log(
+      `${fileName} already exists on GitHub; deleting before creating new file.`
+    );
+    await octokit.repos
+      .deleteReleaseAsset({
+        owner: 'electron',
+        repo: targetRepo,
+        asset_id: existingAssets[0].id
+      })
+      .catch((err) => {
+        console.error(`${fail} Error deleting ${fileName} on GitHub:`, err);
+        process.exit(1);
+      });
   }
   console.log(`Creating and uploading the release ${fileName}.`);
   const checksums = await mergeShasums(pkgVersion);
@@ -286,31 +337,37 @@ async function createReleaseShasums (release) {
   console.log(`${pass} Successfully uploaded ${fileName} to GitHub.`);
 }
 
-async function uploadShasumFile (filePath, fileName, releaseId) {
+async function uploadShasumFile (
+  filePath: string,
+  fileName: string,
+  releaseId: number
+) {
   const uploadUrl = `https://uploads.github.com/repos/electron/${targetRepo}/releases/${releaseId}/assets{?name,label}`;
-  return octokit.repos.uploadReleaseAsset({
-    url: uploadUrl,
-    headers: {
-      'content-type': 'text/plain',
-      'content-length': fs.statSync(filePath).size
-    },
-    data: fs.createReadStream(filePath),
-    name: fileName
-  }).catch(err => {
-    console.error(`${fail} Error uploading ${filePath} to GitHub:`, err);
-    process.exit(1);
-  });
+  return octokit.repos
+    .uploadReleaseAsset({
+      url: uploadUrl,
+      headers: {
+        'content-type': 'text/plain',
+        'content-length': statSync(filePath).size
+      },
+      data: createReadStream(filePath),
+      name: fileName
+    } as any)
+    .catch((err) => {
+      console.error(`${fail} Error uploading ${filePath} to GitHub:`, err);
+      process.exit(1);
+    });
 }
 
-function saveShaSumFile (checksums, fileName) {
-  return new Promise(resolve => {
+function saveShaSumFile (checksums: string, fileName: string) {
+  return new Promise<string>((resolve) => {
     temp.open(fileName, (err, info) => {
       if (err) {
         console.error(`${fail} Could not create ${fileName} file`);
         process.exit(1);
       } else {
-        fs.writeFileSync(info.fd, checksums);
-        fs.close(info.fd, (err) => {
+        writeFileSync(info.fd, checksums);
+        close(info.fd, (err) => {
           if (err) {
             console.error(`${fail} Could close ${fileName} file`);
             process.exit(1);
@@ -322,7 +379,7 @@ function saveShaSumFile (checksums, fileName) {
   });
 }
 
-async function publishRelease (release) {
+async function publishRelease (release: MinimalRelease) {
   let makeLatest = false;
   if (!release.prerelease) {
     const currentLatest = await octokit.repos.getLatestRelease({
@@ -330,23 +387,25 @@ async function publishRelease (release) {
       repo: targetRepo
     });
 
-    makeLatest = semver.gte(release.tag_name, currentLatest.data.tag_name);
+    makeLatest = gte(release.tag_name, currentLatest.data.tag_name);
   }
 
-  return octokit.repos.updateRelease({
-    owner: 'electron',
-    repo: targetRepo,
-    release_id: release.id,
-    tag_name: release.tag_name,
-    draft: false,
-    make_latest: makeLatest ? 'true' : 'false'
-  }).catch(err => {
-    console.error(`${fail} Error publishing release:`, err);
-    process.exit(1);
-  });
+  return octokit.repos
+    .updateRelease({
+      owner: 'electron',
+      repo: targetRepo,
+      release_id: release.id,
+      tag_name: release.tag_name,
+      draft: false,
+      make_latest: makeLatest ? 'true' : 'false'
+    })
+    .catch((err) => {
+      console.error(`${fail} Error publishing release:`, err);
+      process.exit(1);
+    });
 }
 
-async function makeRelease (releaseToValidate) {
+async function makeRelease (releaseToValidate: string | boolean) {
   if (releaseToValidate) {
     if (releaseToValidate === true) {
       releaseToValidate = pkgVersion;
@@ -371,44 +430,52 @@ async function makeRelease (releaseToValidate) {
     // in index.json, which causes other problems in downstream projects
     uploadIndexJson();
     await publishRelease(draftRelease);
-    console.log(`${pass} SUCCESS!!! Release has been published. Please run ` +
-      '"npm run publish-to-npm" to publish release to npm.');
+    console.log(
+      `${pass} SUCCESS!!! Release has been published. Please run ` +
+        '"npm run publish-to-npm" to publish release to npm.'
+    );
   }
 }
 
 const SHASUM_256_FILENAME = 'SHASUMS256.txt';
 const SHASUM_1_FILENAME = 'SHASUMS.txt';
 
-async function verifyDraftGitHubReleaseAssets (release) {
+async function verifyDraftGitHubReleaseAssets (release: MinimalRelease) {
   console.log('Fetching authenticated GitHub artifact URLs to verify shasums');
 
-  const remoteFilesToHash = await Promise.all(release.assets.map(async asset => {
-    const requestOptions = octokit.repos.getReleaseAsset.endpoint({
-      owner: 'electron',
-      repo: targetRepo,
-      asset_id: asset.id,
-      headers: {
-        Accept: 'application/octet-stream'
+  const remoteFilesToHash = await Promise.all(
+    release.assets.map(async (asset) => {
+      const requestOptions = octokit.repos.getReleaseAsset.endpoint({
+        owner: 'electron',
+        repo: targetRepo,
+        asset_id: asset.id,
+        headers: {
+          Accept: 'application/octet-stream'
+        }
+      });
+
+      const { url, headers } = requestOptions;
+      headers.authorization = `token ${
+        ((await octokit.auth()) as { token: string }).token
+      }`;
+
+      const response = await got(url, {
+        followRedirect: false,
+        method: 'HEAD',
+        headers: headers as any,
+        throwHttpErrors: false
+      });
+
+      if (response.statusCode !== 302 && response.statusCode !== 301) {
+        console.error('Failed to HEAD github asset: ' + url);
+        throw new Error(
+          "Unexpected status HEAD'ing github asset: " + response.statusCode
+        );
       }
-    });
-
-    const { url, headers } = requestOptions;
-    headers.authorization = `token ${(await octokit.auth()).token}`;
 
-    const response = await got(url, {
-      followRedirect: false,
-      method: 'HEAD',
-      headers,
-      throwHttpErrors: false
-    });
-
-    if (response.statusCode !== 302 && response.statusCode !== 301) {
-      console.error('Failed to HEAD github asset: ' + url);
-      throw new Error('Unexpected status HEAD\'ing github asset: ' + response.statusCode);
-    }
-
-    return { url: response.headers.location, file: asset.name };
-  })).catch(err => {
+      return { url: response.headers.location!, file: asset.name };
+    })
+  ).catch((err) => {
     console.error(`${fail} Error downloading files from GitHub`, err);
     process.exit(1);
   });
@@ -416,7 +483,10 @@ async function verifyDraftGitHubReleaseAssets (release) {
   await verifyShasumsForRemoteFiles(remoteFilesToHash);
 }
 
-async function getShaSumMappingFromUrl (shaSumFileUrl, fileNamePrefix) {
+async function getShaSumMappingFromUrl (
+  shaSumFileUrl: string,
+  fileNamePrefix: string
+) {
   const response = await got(shaSumFileUrl, {
     throwHttpErrors: false
   });
@@ -424,55 +494,115 @@ async function getShaSumMappingFromUrl (shaSumFileUrl, fileNamePrefix) {
   if (response.statusCode !== 200) {
     console.error('Failed to fetch SHASUM mapping: ' + shaSumFileUrl);
     console.error('Bad SHASUM mapping response: ' + response.body.trim());
-    throw new Error('Unexpected status fetching SHASUM mapping: ' + response.statusCode);
+    throw new Error(
+      'Unexpected status fetching SHASUM mapping: ' + response.statusCode
+    );
   }
 
   const raw = response.body;
-  return raw.split('\n').map(line => line.trim()).filter(Boolean).reduce((map, line) => {
-    const [sha, file] = line.replace('  ', ' ').split(' ');
-    map[file.slice(fileNamePrefix.length)] = sha;
-    return map;
-  }, {});
+  return raw
+    .split('\n')
+    .map((line) => line.trim())
+    .filter(Boolean)
+    .reduce((map, line) => {
+      const [sha, file] = line.replace('  ', ' ').split(' ');
+      map[file.slice(fileNamePrefix.length)] = sha;
+      return map;
+    }, Object.create(null) as Record<string, string>);
 }
 
-async function validateFileHashesAgainstShaSumMapping (remoteFilesWithHashes, mapping) {
+type HashedFile = HashableFile & {
+  hash: string;
+};
+
+type HashableFile = {
+  file: string;
+  url: string;
+};
+
+async function validateFileHashesAgainstShaSumMapping (
+  remoteFilesWithHashes: HashedFile[],
+  mapping: Record<string, string>
+) {
   for (const remoteFileWithHash of remoteFilesWithHashes) {
-    check(remoteFileWithHash.hash === mapping[remoteFileWithHash.file], `Release asset ${remoteFileWithHash.file} should have hash of ${mapping[remoteFileWithHash.file]} but found ${remoteFileWithHash.hash}`, true);
+    check(
+      remoteFileWithHash.hash === mapping[remoteFileWithHash.file],
+      `Release asset ${remoteFileWithHash.file} should have hash of ${
+        mapping[remoteFileWithHash.file]
+      } but found ${remoteFileWithHash.hash}`,
+      true
+    );
   }
 }
 
-async function verifyShasumsForRemoteFiles (remoteFilesToHash, filesAreNodeJSArtifacts = false) {
-  console.log(`Generating SHAs for ${remoteFilesToHash.length} files to verify shasums`);
+async function verifyShasumsForRemoteFiles (
+  remoteFilesToHash: HashableFile[],
+  filesAreNodeJSArtifacts = false
+) {
+  console.log(
+    `Generating SHAs for ${remoteFilesToHash.length} files to verify shasums`
+  );
 
   // Only used for node.js artifact uploads
-  const shaSum1File = remoteFilesToHash.find(({ file }) => file === SHASUM_1_FILENAME);
+  const shaSum1File = remoteFilesToHash.find(
+    ({ file }) => file === SHASUM_1_FILENAME
+  )!;
   // Used for both node.js artifact uploads and normal electron artifacts
-  const shaSum256File = remoteFilesToHash.find(({ file }) => file === SHASUM_256_FILENAME);
-  remoteFilesToHash = remoteFilesToHash.filter(({ file }) => file !== SHASUM_1_FILENAME && file !== SHASUM_256_FILENAME);
-
-  const remoteFilesWithHashes = await Promise.all(remoteFilesToHash.map(async (file) => {
-    return {
-      hash: await getUrlHash(file.url, 'sha256'),
-      ...file
-    };
-  }));
-
-  await validateFileHashesAgainstShaSumMapping(remoteFilesWithHashes, await getShaSumMappingFromUrl(shaSum256File.url, filesAreNodeJSArtifacts ? '' : '*'));
-
-  if (filesAreNodeJSArtifacts) {
-    const remoteFilesWithSha1Hashes = await Promise.all(remoteFilesToHash.map(async (file) => {
+  const shaSum256File = remoteFilesToHash.find(
+    ({ file }) => file === SHASUM_256_FILENAME
+  )!;
+  remoteFilesToHash = remoteFilesToHash.filter(
+    ({ file }) => file !== SHASUM_1_FILENAME && file !== SHASUM_256_FILENAME
+  );
+
+  const remoteFilesWithHashes = await Promise.all(
+    remoteFilesToHash.map(async (file) => {
       return {
-        hash: await getUrlHash(file.url, 'sha1'),
+        hash: await getUrlHash(file.url, 'sha256'),
         ...file
       };
-    }));
+    })
+  );
+
+  await validateFileHashesAgainstShaSumMapping(
+    remoteFilesWithHashes,
+    await getShaSumMappingFromUrl(
+      shaSum256File.url,
+      filesAreNodeJSArtifacts ? '' : '*'
+    )
+  );
 
-    await validateFileHashesAgainstShaSumMapping(remoteFilesWithSha1Hashes, await getShaSumMappingFromUrl(shaSum1File.url, filesAreNodeJSArtifacts ? '' : '*'));
+  if (filesAreNodeJSArtifacts) {
+    const remoteFilesWithSha1Hashes = await Promise.all(
+      remoteFilesToHash.map(async (file) => {
+        return {
+          hash: await getUrlHash(file.url, 'sha1'),
+          ...file
+        };
+      })
+    );
+
+    await validateFileHashesAgainstShaSumMapping(
+      remoteFilesWithSha1Hashes,
+      await getShaSumMappingFromUrl(
+        shaSum1File.url,
+        filesAreNodeJSArtifacts ? '' : '*'
+      )
+    );
   }
 }
 
-makeRelease(args.validateRelease)
-  .catch((err) => {
-    console.error('Error occurred while making release:', err);
-    process.exit(1);
-  });
+const {
+  values: { validateRelease }
+} = parseArgs({
+  options: {
+    validateRelease: {
+      type: 'boolean'
+    }
+  }
+});
+
+makeRelease(!!validateRelease).catch((err) => {
+  console.error('Error occurred while making release:', err);
+  process.exit(1);
+});

+ 7 - 0
script/release/types.ts

@@ -0,0 +1,7 @@
+export const ELECTRON_ORG = 'electron';
+export const ELECTRON_REPO = 'electron';
+export const NIGHTLY_REPO = 'nightlies';
+
+export type ElectronReleaseRepo = 'electron' | 'nightlies';
+
+export type VersionBumpType = 'nightly' | 'alpha' | 'beta' | 'minor' | 'stable';

+ 0 - 2
script/release/uploaders/upload-to-github.ts

@@ -2,8 +2,6 @@ import { Octokit } from '@octokit/rest';
 import * as fs from 'node:fs';
 import { createGitHubTokenStrategy } from '../github-token';
 
-if (!process.env.CI) require('dotenv-safe').load();
-
 if (process.argv.length < 6) {
   console.log('Usage: upload-to-github filePath fileName releaseId');
   process.exit(1);

+ 2 - 2
script/release/uploaders/upload.py

@@ -385,12 +385,12 @@ def upload_sha256_checksum(version, file_path, key_prefix=None):
 
 def get_release(version):
   script_path = os.path.join(
-    ELECTRON_DIR, 'script', 'release', 'find-github-release.js')
+    ELECTRON_DIR, 'script', 'release', 'find-github-release.ts')
 
   # Strip warnings from stdout to ensure the only output is the desired object
   release_env = os.environ.copy()
   release_env['NODE_NO_WARNINGS'] = '1'
-  release_info = execute(['node', script_path, version], release_env)
+  release_info = execute([TS_NODE, script_path, version], release_env)
   if is_verbose_mode():
     print(f'Release info for version: {version}:\n')
     print(release_info)

+ 0 - 99
script/release/version-bumper.js

@@ -1,99 +0,0 @@
-#!/usr/bin/env node
-
-const semver = require('semver');
-const minimist = require('minimist');
-
-const { getElectronVersion } = require('../lib/get-version');
-const versionUtils = require('./version-utils');
-
-function parseCommandLine () {
-  let help;
-  const opts = minimist(process.argv.slice(2), {
-    string: ['bump', 'version'],
-    boolean: ['dryRun', 'help'],
-    alias: { version: ['v'] },
-    unknown: () => { help = true; }
-  });
-  if (help || opts.help || !opts.bump) {
-    console.log(`
-      Bump release version number. Possible arguments:\n
-        --bump=patch to increment patch version\n
-        --version={version} to set version number directly\n
-        --dryRun to print the next version without updating files
-      Note that you can use both --bump and --stable  simultaneously.
-    `);
-    process.exit(0);
-  }
-  return opts;
-}
-
-// run the script
-async function main () {
-  const opts = parseCommandLine();
-  const currentVersion = getElectronVersion();
-  const version = await nextVersion(opts.bump, currentVersion);
-
-  // print would-be new version and exit early
-  if (opts.dryRun) {
-    console.log(`new version number would be: ${version}\n`);
-    return 0;
-  }
-
-  console.log(`Bumped to version: ${version}`);
-}
-
-// get next version for release based on [nightly, alpha, beta, stable]
-async function nextVersion (bumpType, version) {
-  if (
-    versionUtils.isNightly(version) ||
-    versionUtils.isAlpha(version) ||
-    versionUtils.isBeta(version)
-  ) {
-    switch (bumpType) {
-      case 'nightly':
-        version = await versionUtils.nextNightly(version);
-        break;
-      case 'alpha':
-        version = await versionUtils.nextAlpha(version);
-        break;
-      case 'beta':
-        version = await versionUtils.nextBeta(version);
-        break;
-      case 'stable':
-        version = semver.valid(semver.coerce(version));
-        break;
-      default:
-        throw new Error('Invalid bump type.');
-    }
-  } else if (versionUtils.isStable(version)) {
-    switch (bumpType) {
-      case 'nightly':
-        version = versionUtils.nextNightly(version);
-        break;
-      case 'alpha':
-        throw new Error('Cannot bump to alpha from stable.');
-      case 'beta':
-        throw new Error('Cannot bump to beta from stable.');
-      case 'minor':
-        version = semver.inc(version, 'minor');
-        break;
-      case 'stable':
-        version = semver.inc(version, 'patch');
-        break;
-      default:
-        throw new Error('Invalid bump type.');
-    }
-  } else {
-    throw new Error(`Invalid current version: ${version}`);
-  }
-  return version;
-}
-
-if (require.main === module) {
-  main().catch((error) => {
-    console.error(error);
-    process.exit(1);
-  });
-}
-
-module.exports = { nextVersion };

+ 106 - 0
script/release/version-bumper.ts

@@ -0,0 +1,106 @@
+#!/usr/bin/env node
+
+import { valid, coerce, inc } from 'semver';
+
+import { getElectronVersion } from '../lib/get-version';
+import {
+  isNightly,
+  isAlpha,
+  isBeta,
+  nextNightly,
+  nextAlpha,
+  nextBeta,
+  isStable
+} from './version-utils';
+import { VersionBumpType } from './types';
+import { parseArgs } from 'node:util';
+
+// run the script
+async function main () {
+  const { values: { bump, dryRun, help } } = parseArgs({
+    options: {
+      bump: {
+        type: 'string'
+      },
+      dryRun: {
+        type: 'boolean'
+      },
+      help: {
+        type: 'boolean'
+      }
+    }
+  });
+
+  if (!bump || help) {
+    console.log(`
+        Bump release version number. Possible arguments:\n
+          --bump=patch to increment patch version\n
+          --version={version} to set version number directly\n
+          --dryRun to print the next version without updating files
+        Note that you can use both --bump and --stable  simultaneously.
+      `);
+    if (!bump) process.exit(0);
+    else process.exit(1);
+  }
+
+  const currentVersion = getElectronVersion();
+  const version = await nextVersion(bump as VersionBumpType, currentVersion);
+
+  // print would-be new version and exit early
+  if (dryRun) {
+    console.log(`new version number would be: ${version}\n`);
+    return 0;
+  }
+
+  console.log(`Bumped to version: ${version}`);
+}
+
+// get next version for release based on [nightly, alpha, beta, stable]
+export async function nextVersion (bumpType: VersionBumpType, version: string) {
+  if (isNightly(version) || isAlpha(version) || isBeta(version)) {
+    switch (bumpType) {
+      case 'nightly':
+        version = await nextNightly(version);
+        break;
+      case 'alpha':
+        version = await nextAlpha(version);
+        break;
+      case 'beta':
+        version = await nextBeta(version);
+        break;
+      case 'stable':
+        version = valid(coerce(version))!;
+        break;
+      default:
+        throw new Error('Invalid bump type.');
+    }
+  } else if (isStable(version)) {
+    switch (bumpType) {
+      case 'nightly':
+        version = await nextNightly(version);
+        break;
+      case 'alpha':
+        throw new Error('Cannot bump to alpha from stable.');
+      case 'beta':
+        throw new Error('Cannot bump to beta from stable.');
+      case 'minor':
+        version = inc(version, 'minor')!;
+        break;
+      case 'stable':
+        version = inc(version, 'patch')!;
+        break;
+      default:
+        throw new Error('Invalid bump type.');
+    }
+  } else {
+    throw new Error(`Invalid current version: ${version}`);
+  }
+  return version;
+}
+
+if (require.main === module) {
+  main().catch((error) => {
+    console.error(error);
+    process.exit(1);
+  });
+}

+ 23 - 45
script/release/version-utils.js → script/release/version-utils.ts

@@ -1,13 +1,13 @@
-const semver = require('semver');
-const { GitProcess } = require('dugite');
+import * as semver from 'semver';
+import { GitProcess } from 'dugite';
 
-const { ELECTRON_DIR } = require('../lib/utils');
+import { ELECTRON_DIR } from '../lib/utils';
 
-const preType = {
-  NONE: 'none',
-  PARTIAL: 'partial',
-  FULL: 'full'
-};
+export enum PreType {
+  NONE = 'none',
+  PARTIAL = ' partial',
+  FULL = 'full',
+}
 
 const getCurrentDate = () => {
   const d = new Date();
@@ -17,53 +17,43 @@ const getCurrentDate = () => {
   return `${yyyy}${mm}${dd}`;
 };
 
-const isNightly = v => v.includes('nightly');
-const isAlpha = v => v.includes('alpha');
-const isBeta = v => v.includes('beta');
-const isStable = v => {
+export const isNightly = (v: string) => v.includes('nightly');
+export const isAlpha = (v: string) => v.includes('alpha');
+export const isBeta = (v: string) => v.includes('beta');
+export const isStable = (v: string) => {
   const parsed = semver.parse(v);
   return !!(parsed && parsed.prerelease.length === 0);
 };
 
-const makeVersion = (components, delim, pre = preType.NONE) => {
-  let version = [components.major, components.minor, components.patch].join(delim);
-  if (pre === preType.PARTIAL) {
-    version += `${delim}${components.pre[1] || 0}`;
-  } else if (pre === preType.FULL) {
-    version += `-${components.pre[0]}${delim}${components.pre[1]}`;
-  }
-  return version;
-};
-
-async function nextAlpha (v) {
+export async function nextAlpha (v: string) {
   const next = semver.coerce(semver.clean(v));
   const tagBlob = await GitProcess.exec(['tag', '--list', '-l', `v${next}-alpha.*`], ELECTRON_DIR);
   const tags = tagBlob.stdout.split('\n').filter(e => e !== '');
   tags.sort((t1, t2) => {
-    const a = parseInt(t1.split('.').pop(), 10);
-    const b = parseInt(t2.split('.').pop(), 10);
+    const a = parseInt(t1.split('.').pop()!, 10);
+    const b = parseInt(t2.split('.').pop()!, 10);
     return a - b;
   });
 
   // increment the latest existing alpha tag or start at alpha.1 if it's a new alpha line
-  return tags.length === 0 ? `${next}-alpha.1` : semver.inc(tags.pop(), 'prerelease');
+  return tags.length === 0 ? `${next}-alpha.1` : semver.inc(tags.pop()!, 'prerelease')!;
 }
 
-async function nextBeta (v) {
+export async function nextBeta (v: string) {
   const next = semver.coerce(semver.clean(v));
   const tagBlob = await GitProcess.exec(['tag', '--list', '-l', `v${next}-beta.*`], ELECTRON_DIR);
   const tags = tagBlob.stdout.split('\n').filter(e => e !== '');
   tags.sort((t1, t2) => {
-    const a = parseInt(t1.split('.').pop(), 10);
-    const b = parseInt(t2.split('.').pop(), 10);
+    const a = parseInt(t1.split('.').pop()!, 10);
+    const b = parseInt(t2.split('.').pop()!, 10);
     return a - b;
   });
 
   // increment the latest existing beta tag or start at beta.1 if it's a new beta line
-  return tags.length === 0 ? `${next}-beta.1` : semver.inc(tags.pop(), 'prerelease');
+  return tags.length === 0 ? `${next}-beta.1` : semver.inc(tags.pop()!, 'prerelease')!;
 }
 
-async function nextNightly (v) {
+export async function nextNightly (v: string) {
   let next = semver.valid(semver.coerce(v));
   const pre = `nightly.${getCurrentDate()}`;
 
@@ -71,7 +61,7 @@ async function nextNightly (v) {
   if (branch === 'main') {
     next = semver.inc(await getLastMajorForMain(), 'major');
   } else if (isStable(v)) {
-    next = semver.inc(next, 'patch');
+    next = semver.inc(next!, 'patch');
   }
 
   return `${next}-${pre}`;
@@ -89,19 +79,7 @@ async function getLastMajorForMain () {
   }
 }
 
-function getNextReleaseBranch (branches) {
+function getNextReleaseBranch (branches: string[]) {
   const converted = branches.map(b => b.replace(/-/g, '.').replace('x', '0').replace('y', '0'));
   return converted.reduce((v1, v2) => semver.gt(v1, v2) ? v1 : v2);
 }
-
-module.exports = {
-  isStable,
-  isAlpha,
-  isBeta,
-  isNightly,
-  nextAlpha,
-  nextBeta,
-  makeVersion,
-  nextNightly,
-  preType
-};

+ 1 - 1
spec/release-notes-spec.ts

@@ -1,6 +1,6 @@
 import { GitProcess, IGitExecutionOptions, IGitResult } from 'dugite';
 import { expect } from 'chai';
-import * as notes from '../script/release/notes/notes.js';
+import * as notes from '../script/release/notes/notes';
 import * as path from 'node:path';
 import * as sinon from 'sinon';
 

+ 1 - 38
spec/version-bump-spec.ts

@@ -1,7 +1,6 @@
 import { expect } from 'chai';
 import { GitProcess, IGitExecutionOptions, IGitResult } from 'dugite';
 import { nextVersion } from '../script/release/version-bumper';
-import * as utils from '../script/release/version-utils';
 import * as sinon from 'sinon';
 import { ifdescribe } from './lib/spec-helpers';
 
@@ -53,43 +52,6 @@ class GitFake {
 }
 
 describe('version-bumper', () => {
-  describe('makeVersion', () => {
-    it('makes a version with a period delimiter', () => {
-      const components = {
-        major: 2,
-        minor: 0,
-        patch: 0
-      };
-
-      const version = utils.makeVersion(components, '.');
-      expect(version).to.equal('2.0.0');
-    });
-
-    it('makes a version with a period delimiter and a partial pre', () => {
-      const components = {
-        major: 2,
-        minor: 0,
-        patch: 0,
-        pre: ['nightly', 12345678]
-      };
-
-      const version = utils.makeVersion(components, '.', utils.preType.PARTIAL);
-      expect(version).to.equal('2.0.0.12345678');
-    });
-
-    it('makes a version with a period delimiter and a full pre', () => {
-      const components = {
-        major: 2,
-        minor: 0,
-        patch: 0,
-        pre: ['nightly', 12345678]
-      };
-
-      const version = utils.makeVersion(components, '.', utils.preType.FULL);
-      expect(version).to.equal('2.0.0-nightly.12345678');
-    });
-  });
-
   ifdescribe(!(process.platform === 'linux' && process.arch.indexOf('arm') === 0) && process.platform !== 'darwin')('nextVersion', () => {
     describe('bump versions', () => {
       const nightlyPattern = /[0-9.]*(-nightly.(\d{4})(\d{2})(\d{2}))$/g;
@@ -183,6 +145,7 @@ describe('version-bumper', () => {
       it('throws on an invalid bump type', () => {
         const version = 'v2.0.0';
         return expect(
+          // @ts-expect-error 'WRONG' is not a valid bump type
           nextVersion('WRONG', version)
         ).to.be.rejectedWith('Invalid bump type.');
       });

+ 4 - 16
yarn.lock

@@ -2160,18 +2160,6 @@ doctrine@^3.0.0:
   dependencies:
     esutils "^2.0.2"
 
-dotenv-safe@^4.0.4:
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/dotenv-safe/-/dotenv-safe-4.0.4.tgz#8b0e7ced8e70b1d3c5d874ef9420e406f39425b3"
-  integrity sha1-iw587Y5wsdPF2HTvlCDkBvOUJbM=
-  dependencies:
-    dotenv "^4.0.0"
-
-dotenv@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-4.0.0.tgz#864ef1379aced55ce6f95debecdce179f7a0cd1d"
-  integrity sha1-hk7xN5rO1Vzm+V3r7NzhefegzR0=
-
 dugite@^2.7.1:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/dugite/-/dugite-2.7.1.tgz#277275fd490bddf20180e124d119f84f708dfb32"
@@ -7075,10 +7063,10 @@ typedarray@^0.0.6:
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-typescript@^5.1.2:
-  version "5.1.3"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.3.tgz#8d84219244a6b40b6fb2b33cc1c062f715b9e826"
-  integrity sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==
+typescript@^5.6.2:
+  version "5.6.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0"
+  integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==
 
 uc.micro@^1.0.1, uc.micro@^1.0.5:
   version "1.0.6"