Browse Source

chore: backport release script updates to 2-0-x (#14191)

* chore: alter release scripts to enable sudowoodo

* add example .env file

* chore: only prepare release if last commit not a bump (#14193)
Shelley Vohr 6 years ago
parent
commit
74d90fbb33

+ 7 - 0
.env.example

@@ -0,0 +1,7 @@
+# These env vars are only necessary for creating Electron releases.
+# See docs/development/releasing.md
+
+APPVEYOR_TOKEN=
+CIRCLE_TOKEN=
+ELECTRON_GITHUB_TOKEN=
+VSTS_TOKEN=

+ 57 - 33
script/bump-version.py

@@ -5,12 +5,12 @@ import re
 import sys
 import argparse
 
-from lib.util import execute, get_electron_version, parse_version, scoped_cwd
-
+from lib.util import execute, get_electron_version, parse_version, scoped_cwd, \
+is_nightly, is_beta, is_stable, get_next_nightly, get_next_beta, \
+get_next_stable_from_pre, get_next_stable_from_stable, clean_parse_version
 
 SOURCE_ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
 
-
 def main():
 
   parser = argparse.ArgumentParser(
@@ -34,14 +34,7 @@ def main():
     action='store',
     default=None,
     dest='bump',
-    help='increment [major | minor | patch | beta]'
-  )
-  parser.add_argument(
-    '--stable',
-    action='store_true',
-    default= False,
-    dest='stable',
-    help='promote to stable (i.e. remove `-beta.x` suffix)'
+    help='increment [stable | beta | nightly]'
   )
   parser.add_argument(
     '--dry-run',
@@ -52,36 +45,56 @@ def main():
   )
 
   args = parser.parse_args()
+  curr_version = get_electron_version()
+
+  if args.bump not in ['stable', 'beta', 'nightly']:
+    raise Exception('bump must be set to either stable, beta or nightly')
+
+  if is_nightly(curr_version):
+    if args.bump == 'nightly':
+      version = get_next_nightly(curr_version)
+    elif args.bump == 'beta':
+      version = get_next_beta(curr_version)
+    elif args.bump == 'stable':
+      version = get_next_stable_from_pre(curr_version)
+    else:
+      not_reached()
+  elif is_beta(curr_version):
+    if args.bump == 'nightly':
+      version = get_next_nightly(curr_version)
+    elif args.bump == 'beta':
+      version = get_next_beta(curr_version)
+    elif args.bump == 'stable':
+      version = get_next_stable_from_pre(curr_version)
+    else:
+      not_reached()
+  elif is_stable(curr_version):
+    if args.bump == 'nightly':
+      version = get_next_nightly(curr_version)
+    elif args.bump == 'beta':
+      raise Exception("You can\'t bump to a beta from stable")
+    elif args.bump == 'stable':
+      version = get_next_stable_from_stable(curr_version)
+    else:
+      not_reached()
+  else:
+    raise Exception("Invalid current version: " + curr_version)
 
   if args.new_version == None and args.bump == None and args.stable == False:
     parser.print_help()
     return 1
 
-  increments = ['major', 'minor', 'patch', 'beta']
-
-  curr_version = get_electron_version()
-  versions = parse_version(re.sub('-beta', '', curr_version))
-
-  if args.bump in increments:
-    versions = increase_version(versions, increments.index(args.bump))
-    if versions[3] == '0':
-      # beta starts at 1
-      versions = increase_version(versions, increments.index('beta'))
-
-  if args.stable == True:
-    versions[3] = '0'
-
-  if args.new_version != None:
-    versions = parse_version(re.sub('-beta', '', args.new_version))
-
-  version = '.'.join(versions[:3])
-  suffix = '' if versions[3] == '0' else '-beta.' + versions[3]
+  versions = clean_parse_version(version)
+  suffix = ''
+  if '-' in version:
+    suffix = '-' + version.split('-')[1]
+    versions[3] = parse_version(version)[3]
+  version = version.split('-')[0]
 
   if args.dry_run:
     print 'new version number would be: {0}\n'.format(version + suffix)
     return 0
 
-
   with scoped_cwd(SOURCE_ROOT):
     update_electron_gyp(version, suffix)
     update_win_rc(version, versions)
@@ -92,6 +105,9 @@ def main():
 
   print 'Bumped to version: {0}'.format(version + suffix)
 
+def not_reached():
+  raise Exception('Unreachable code was reached')
+
 def increase_version(versions, index):
   for i in range(index + 1, 4):
     versions[i] = '0'
@@ -100,7 +116,8 @@ def increase_version(versions, index):
 
 
 def update_electron_gyp(version, suffix):
-  pattern = re.compile(" *'version%' *: *'[0-9.]+(-beta[0-9.]*)?'")
+  pattern = re.compile(" *'version%' *: *'[0-9.]+(-beta[0-9.]*)?(-dev)?"
+    + "(-nightly[0-9.]*)?'")
   with open('electron.gyp', 'r') as f:
     lines = f.readlines()
 
@@ -192,7 +209,14 @@ def update_package_json(version, suffix):
 
 
 def tag_version(version, suffix):
-  execute(['git', 'commit', '-a', '-m', 'Bump v{0}'.format(version + suffix)])
+  execute([
+    'git',
+    'commit',
+    '-a',
+    '-m',
+    'Bump v{0}'.format(version + suffix),
+    '-n'
+  ])
 
 
 if __name__ == '__main__':

+ 2 - 3
script/ci-release-build.js

@@ -1,3 +1,5 @@
+require('dotenv-safe').load()
+
 const assert = require('assert')
 const request = require('request')
 const buildAppVeyorURL = 'https://windows-ci.electronjs.org/api/builds'
@@ -44,7 +46,6 @@ async function makeRequest (requestOptions, parseResponse) {
 }
 
 async function circleCIcall (buildUrl, targetBranch, job, options) {
-  assert(process.env.CIRCLE_TOKEN, 'CIRCLE_TOKEN not found in environment')
   console.log(`Triggering CircleCI to run build job: ${job} on branch: ${targetBranch} with release flag.`)
   let buildRequest = {
     'build_parameters': {
@@ -77,7 +78,6 @@ async function circleCIcall (buildUrl, targetBranch, job, options) {
 }
 
 function buildAppVeyor (targetBranch, options) {
-  assert(process.env.APPVEYOR_TOKEN, 'APPVEYOR_TOKEN not found in environment')
   const validJobs = Object.keys(appVeyorJobs)
   if (options.job) {
     assert(validJobs.includes(options.job), `Unknown AppVeyor CI job name: ${options.job}.  Valid values are: ${validJobs}.`)
@@ -139,7 +139,6 @@ async function buildVSTS (targetBranch, options) {
     assert(vstsJobs.includes(options.job), `Unknown VSTS CI job name: ${options.job}. Valid values are: ${vstsJobs}.`)
   }
   console.log(`Triggering VSTS to run build on branch: ${targetBranch} with release flag.`)
-  assert(process.env.VSTS_TOKEN, 'VSTS_TOKEN not found in environment')
   let environmentVariables = {}
 
   if (!options.ghRelease) {

+ 3 - 1
script/find-release.js

@@ -1,3 +1,5 @@
+if (!process.env.CI) require('dotenv-safe').load()
+
 const GitHub = require('github')
 const github = new GitHub()
 
@@ -12,7 +14,7 @@ async function findRelease () {
   github.authenticate({type: 'token', token: process.env.ELECTRON_GITHUB_TOKEN})
   let releases = await github.repos.getReleases({
     owner: 'electron',
-    repo: 'electron'
+    repo: version.indexOf('nightly') > 0 ? 'nightlies' : 'electron'
   })
   let targetRelease = releases.data.find(release => {
     return release.tag_name === version

+ 29 - 0
script/get-last-major-for-master.js

@@ -0,0 +1,29 @@
+const { GitProcess } = require('dugite')
+const path = require('path')
+const semver = require('semver')
+const gitDir = path.resolve(__dirname, '..')
+
+async function determineNextMajorForMaster () {
+  let branchNames
+  let result = await GitProcess.exec(['branch', '-a', '--remote', '--list', 'origin/[0-9]-[0-9]-x'], gitDir)
+  if (result.exitCode === 0) {
+    branchNames = result.stdout.trim().split('\n')
+    const filtered = branchNames.map(b => b.replace('origin/', ''))
+    return getNextReleaseBranch(filtered)
+  } else {
+    throw new Error('Release branches could not be fetched.')
+  }
+}
+
+function getNextReleaseBranch (branches) {
+  const converted = branches.map(b => b.replace(/-/g, '.').replace('x', '0'))
+  const next = converted.reduce((v1, v2) => {
+    return semver.gt(v1, v2) ? v1 : v2
+  })
+  return parseInt(next.split('.')[0], 10)
+}
+
+determineNextMajorForMaster().then(console.info).catch((err) => {
+  console.error(err)
+  process.exit(1)
+})

+ 66 - 1
script/lib/util.py

@@ -2,6 +2,7 @@
 
 import atexit
 import contextlib
+import datetime
 import errno
 import platform
 import re
@@ -87,7 +88,7 @@ def download(text, url, path):
     downloaded_size = 0
     block_size = 128
 
-    ci = os.environ.get('CI') == '1'
+    ci = os.environ.get('CI') is not None
 
     while True:
       buf = web_file.read(block_size)
@@ -287,3 +288,67 @@ def update_node_modules(dirname, env=None):
         pass
     else:
       execute_stdout(args, env)
+
+def clean_parse_version(v):
+  return parse_version(v.split("-")[0])
+
+def is_stable(v):
+  return len(v.split(".")) == 3
+
+def is_beta(v):
+  return 'beta' in v
+
+def is_nightly(v):
+  return 'nightly' in v
+
+def get_nightly_date():
+  return datetime.datetime.today().strftime('%Y%m%d')
+
+def get_last_major():
+  return execute(['node', 'script/get-last-major-for-master.js'])
+
+def get_next_nightly(v):
+  pv = clean_parse_version(v)
+  major = pv[0]; minor = pv[1]; patch = pv[2]
+
+  if (is_stable(v)):
+    patch = str(int(pv[2]) + 1)
+
+  if execute(['git', 'rev-parse', '--abbrev-ref', 'HEAD']) == "master":
+    major = str(get_last_major() + 1)
+    minor = '0'
+    patch = '0'
+
+  pre = 'nightly.' + get_nightly_date()
+  return make_version(major, minor, patch, pre)
+
+def non_empty(thing):
+  return thing.strip() != ''
+
+def get_next_beta(v):
+  pv = clean_parse_version(v)
+  tag_pattern = 'v' + pv[0] + '.' + pv[1] + '.' + pv[2] + '-beta.*'
+  tag_list = filter(
+    non_empty,
+    execute(['git', 'tag', '--list', '-l', tag_pattern]).strip().split('\n')
+  )
+  if len(tag_list) == 0:
+    return make_version(pv[0] , pv[1],  pv[2], 'beta.1')
+
+  lv = parse_version(tag_list[-1])
+  return make_version(pv[0] , pv[1],  pv[2], 'beta.' + str(int(lv[3]) + 1))
+
+def get_next_stable_from_pre(v):
+  pv = clean_parse_version(v)
+  major = pv[0]; minor = pv[1]; patch = pv[2]
+  return make_version(major, minor, patch)
+
+def get_next_stable_from_stable(v):
+  pv = clean_parse_version(v)
+  major = pv[0]; minor = pv[1]; patch = pv[2]
+  return make_version(major, minor, str(int(patch) + 1))
+
+def make_version(major, minor, patch, pre = None):
+  if pre is None:
+    return major + '.' + minor + '.' + patch
+  return major + "." + minor + "." + patch + '-' + pre

+ 43 - 19
script/prepare-release.js

@@ -1,10 +1,10 @@
 #!/usr/bin/env node
 
+if (!process.env.CI) require('dotenv-safe').load()
 require('colors')
 const args = require('minimist')(process.argv.slice(2), {
   boolean: ['automaticRelease', 'notesOnly', 'stable']
 })
-const assert = require('assert')
 const ciReleaseBuild = require('./ci-release-build')
 const { execSync } = require('child_process')
 const fail = '\u2717'.red
@@ -15,13 +15,13 @@ const path = require('path')
 const pkg = require('../package.json')
 const readline = require('readline')
 const versionType = args._[0]
+const targetRepo = versionType === 'nightly' ? 'nightlies' : 'electron'
 
 // TODO (future) automatically determine version based on conventional commits
 // via conventional-recommended-bump
 
-assert(process.env.ELECTRON_GITHUB_TOKEN, 'ELECTRON_GITHUB_TOKEN not found in environment')
 if (!versionType && !args.notesOnly) {
-  console.log(`Usage: prepare-release versionType [major | minor | patch | beta]` +
+  console.log(`Usage: prepare-release versionType [stable | beta | nightly]` +
      ` (--stable) (--notesOnly) (--automaticRelease) (--branch)`)
   process.exit(1)
 }
@@ -30,10 +30,10 @@ const github = new GitHub()
 const gitDir = path.resolve(__dirname, '..')
 github.authenticate({type: 'token', token: process.env.ELECTRON_GITHUB_TOKEN})
 
-function getNewVersion (dryRun) {
+async function getNewVersion (dryRun) {
   console.log(`Bumping for new "${versionType}" version.`)
   let bumpScript = path.join(__dirname, 'bump-version.py')
-  let scriptArgs = [bumpScript, `--bump ${versionType}`]
+  let scriptArgs = [bumpScript, '--bump', versionType]
   if (args.stable) {
     scriptArgs.push('--stable')
   }
@@ -50,6 +50,7 @@ function getNewVersion (dryRun) {
     return newVersion
   } catch (err) {
     console.log(`${fail} Could not bump version, error was:`, err)
+    throw err
   }
 }
 
@@ -71,10 +72,13 @@ async function getCurrentBranch (gitDir) {
 }
 
 async function getReleaseNotes (currentBranch) {
+  if (versionType === 'nightly') {
+    return 'Nightlies do not get release notes, please compare tags for info'
+  }
   console.log(`Generating release notes for ${currentBranch}.`)
   let githubOpts = {
     owner: 'electron',
-    repo: 'electron',
+    repo: targetRepo,
     base: `v${pkg.version}`,
     head: currentBranch
   }
@@ -136,11 +140,11 @@ async function getReleaseNotes (currentBranch) {
 
 async function createRelease (branchToTarget, isBeta) {
   let releaseNotes = await getReleaseNotes(branchToTarget)
-  let newVersion = getNewVersion()
+  let newVersion = await getNewVersion()
   await tagRelease(newVersion)
   const githubOpts = {
     owner: 'electron',
-    repo: 'electron'
+    repo: targetRepo
   }
   console.log(`Checking for existing draft release.`)
   let releases = await github.repos.getReleases(githubOpts)
@@ -158,10 +162,17 @@ async function createRelease (branchToTarget, isBeta) {
   githubOpts.draft = true
   githubOpts.name = `electron ${newVersion}`
   if (isBeta) {
-    githubOpts.body = `Note: This is a beta release.  Please file new issues ` +
-      `for any bugs you find in it.\n \n This release is published to npm ` +
-      `under the beta tag and can be installed via npm install electron@beta, ` +
-      `or npm i electron@${newVersion.substr(1)}.\n \n ${releaseNotes}`
+    if (newVersion.indexOf('nightly') > 0) {
+      githubOpts.body = `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 ` +
+        `under the nightly tag and can be installed via npm install electron@nightly, ` +
+        `or npm i electron@${newVersion.substr(1)}.\n \n ${releaseNotes}`
+    } else {
+      githubOpts.body = `Note: This is a beta release.  Please file new issues ` +
+        `for any bugs you find in it.\n \n This release is published to npm ` +
+        `under the beta tag and can be installed via npm install electron@beta, ` +
+        `or npm i electron@${newVersion.substr(1)}.\n \n ${releaseNotes}`
+    }
     githubOpts.name = `${githubOpts.name}`
     githubOpts.prerelease = true
   } else {
@@ -209,7 +220,7 @@ async function tagRelease (version) {
 }
 
 async function verifyNewVersion () {
-  let newVersion = getNewVersion(true)
+  let newVersion = await getNewVersion(true)
   let response
   if (args.automaticRelease) {
     response = 'y'
@@ -237,10 +248,17 @@ async function promptForVersion (version) {
   })
 }
 
+// function to determine if there have been commits to master since the last release
+async function changesToRelease () {
+  let lastCommitWasRelease = new RegExp(`Bump v[0-9.]*(-beta[0-9.]*)?(-nightly[0-9.]*)?`, 'g')
+  let lastCommit = await GitProcess.exec(['log', '-n', '1', `--pretty=format:'%s'`], gitDir)
+  return !lastCommitWasRelease.test(lastCommit.stdout)
+}
+
 async function prepareRelease (isBeta, notesOnly) {
   if (args.automaticRelease && (pkg.version.indexOf('beta') === -1 ||
-      versionType !== 'beta')) {
-    console.log(`${fail} Automatic release is only supported for beta releases`)
+      versionType !== 'beta') && versionType !== 'nightly' && versionType !== 'stable') {
+    console.log(`${fail} Automatic release is only supported for beta and nightly releases`)
     process.exit(1)
   }
   let currentBranch
@@ -253,10 +271,16 @@ async function prepareRelease (isBeta, notesOnly) {
     let releaseNotes = await getReleaseNotes(currentBranch)
     console.log(`Draft release notes are: \n${releaseNotes}`)
   } else {
-    await verifyNewVersion()
-    await createRelease(currentBranch, isBeta)
-    await pushRelease(currentBranch)
-    await runReleaseBuilds(currentBranch)
+    const changes = await changesToRelease(currentBranch)
+    if (changes) {
+      await verifyNewVersion()
+      await createRelease(currentBranch, isBeta)
+      await pushRelease(currentBranch)
+      await runReleaseBuilds(currentBranch)
+    } else {
+      console.log(`There are no new changes to this branch since the last release, aborting release.`)
+      process.exit(1)
+    }
   }
 }
 

+ 37 - 7
script/publish-to-npm.js

@@ -3,10 +3,15 @@ const fs = require('fs')
 const path = require('path')
 const childProcess = require('child_process')
 const GitHubApi = require('github')
+const {GitProcess} = require('dugite')
 const request = require('request')
-const assert = require('assert')
 const rootPackageJson = require('../package.json')
 
+if (!process.env.ELECTRON_NPM_OTP) {
+  console.error('Please set ELECTRON_NPM_OTP')
+  process.exit(1)
+}
+
 const github = new GitHubApi({
   // debug: true,
   headers: { 'User-Agent': 'electron-npm-publisher' },
@@ -68,7 +73,7 @@ new Promise((resolve, reject) => {
 
   return github.repos.getReleases({
     owner: 'electron',
-    repo: 'electron'
+    repo: rootPackageJson.version.indexOf('nightly') > 0 ? 'nightlies' : 'electron'
   })
 })
 .then((releases) => {
@@ -103,8 +108,17 @@ new Promise((resolve, reject) => {
     })
   })
 })
-.then((release) => {
-  npmTag = release.prerelease ? 'beta' : 'latest'
+.then(async (release) => {
+  if (release.tag_name.indexOf('nightly') > 0) {
+    const currentBranch = await getCurrentBranch()
+    if (currentBranch === 'master') {
+      npmTag = 'nightly'
+    } else {
+      npmTag = `nightly-${currentBranch}`
+    }
+  } else {
+    npmTag = release.prerelease ? 'beta' : 'latest'
+  }
 })
 .then(() => childProcess.execSync('npm pack', { cwd: tempDir }))
 .then(() => {
@@ -115,13 +129,29 @@ new Promise((resolve, reject) => {
       env: Object.assign({}, process.env, { electron_config_cache: tempDir }),
       cwd: tempDir
     })
-    const checkVersion = childProcess.execSync(`${path.join(tempDir, 'node_modules', '.bin', 'electron')} -v`)
-    assert.ok((`v${rootPackageJson.version}`.indexOf(checkVersion.toString().trim()) === 0), `Version is correct`)
     resolve(tarballPath)
   })
 })
-.then((tarballPath) => childProcess.execSync(`npm publish ${tarballPath} --tag ${npmTag}`))
+.then((tarballPath) => childProcess.execSync(`npm publish ${tarballPath} --tag ${npmTag} --otp=${process.env.ELECTRON_NPM_OTP}`))
 .catch((err) => {
   console.error(`Error: ${err}`)
   process.exit(1)
 })
+
+async function getCurrentBranch () {
+  const gitDir = path.resolve(__dirname, '..')
+  console.log(`Determining current git branch`)
+  let gitArgs = ['rev-parse', '--abbrev-ref', 'HEAD']
+  let branchDetails = await GitProcess.exec(gitArgs, gitDir)
+  if (branchDetails.exitCode === 0) {
+    let currentBranch = branchDetails.stdout.trim()
+    console.log(`Successfully determined current git branch is ` +
+      `${currentBranch}`)
+    return currentBranch
+  } else {
+    let error = GitProcess.parseError(branchDetails.stderr)
+    console.log(`Could not get details for the current branch,
+      error was ${branchDetails.stderr}`, error)
+    process.exit(1)
+  }
+}

+ 478 - 0
script/release-notes/index.js

@@ -0,0 +1,478 @@
+const { GitProcess } = require('dugite')
+const Entities = require('html-entities').AllHtmlEntities
+const fetch = require('node-fetch')
+const fs = require('fs')
+const GitHub = require('github')
+const path = require('path')
+const semver = require('semver')
+
+const CACHE_DIR = path.resolve(__dirname, '.cache')
+// Fill this with tags to ignore if you are generating release notes for older
+// versions
+//
+// E.g. ['v3.0.0-beta.1'] to generate the release notes for 3.0.0-beta.1 :) from
+//      the current 3-0-x branch
+const EXCLUDE_TAGS = []
+
+const entities = new Entities()
+const github = new GitHub()
+const gitDir = path.resolve(__dirname, '..', '..')
+github.authenticate({ type: 'token', token: process.env.ELECTRON_GITHUB_TOKEN })
+let currentBranch
+
+const semanticMap = new Map()
+for (const line of fs.readFileSync(path.resolve(__dirname, 'legacy-pr-semantic-map.csv'), 'utf8').split('\n')) {
+  if (!line) continue
+  const bits = line.split(',')
+  if (bits.length !== 2) continue
+  semanticMap.set(bits[0], bits[1])
+}
+
+const getCurrentBranch = async () => {
+  if (currentBranch) return currentBranch
+  const gitArgs = ['rev-parse', '--abbrev-ref', 'HEAD']
+  const branchDetails = await GitProcess.exec(gitArgs, gitDir)
+  if (branchDetails.exitCode === 0) {
+    currentBranch = branchDetails.stdout.trim()
+    return currentBranch
+  }
+  throw GitProcess.parseError(branchDetails.stderr)
+}
+
+const getBranchOffPoint = async (branchName) => {
+  const gitArgs = ['merge-base', branchName, 'master']
+  const commitDetails = await GitProcess.exec(gitArgs, gitDir)
+  if (commitDetails.exitCode === 0) {
+    return commitDetails.stdout.trim()
+  }
+  throw GitProcess.parseError(commitDetails.stderr)
+}
+
+const getTagsOnBranch = async (branchName) => {
+  const gitArgs = ['tag', '--merged', branchName]
+  const tagDetails = await GitProcess.exec(gitArgs, gitDir)
+  if (tagDetails.exitCode === 0) {
+    return tagDetails.stdout.trim().split('\n').filter(tag => !EXCLUDE_TAGS.includes(tag))
+  }
+  throw GitProcess.parseError(tagDetails.stderr)
+}
+
+const memLastKnownRelease = new Map()
+
+const getLastKnownReleaseOnBranch = async (branchName) => {
+  if (memLastKnownRelease.has(branchName)) {
+    return memLastKnownRelease.get(branchName)
+  }
+  const tags = await getTagsOnBranch(branchName)
+  if (!tags.length) {
+    throw new Error(`Branch ${branchName} has no tags, we have no idea what the last release was`)
+  }
+  const branchOffPointTags = await getTagsOnBranch(await getBranchOffPoint(branchName))
+  if (branchOffPointTags.length >= tags.length) {
+    // No release on this branch
+    return null
+  }
+  memLastKnownRelease.set(branchName, tags[tags.length - 1])
+  // Latest tag is the latest release
+  return tags[tags.length - 1]
+}
+
+const getBranches = async () => {
+  const gitArgs = ['branch', '--remote']
+  const branchDetails = await GitProcess.exec(gitArgs, gitDir)
+  if (branchDetails.exitCode === 0) {
+    return branchDetails.stdout.trim().split('\n').map(b => b.trim()).filter(branch => branch !== 'origin/HEAD -> origin/master')
+  }
+  throw GitProcess.parseError(branchDetails.stderr)
+}
+
+const semverify = (v) => v.replace(/^origin\//, '').replace('x', '0').replace(/-/g, '.')
+
+const getLastReleaseBranch = async () => {
+  const current = await getCurrentBranch()
+  const allBranches = await getBranches()
+  const releaseBranches = allBranches
+    .filter(branch => /^origin\/[0-9]+-[0-9]+-x$/.test(branch))
+    .filter(branch => branch !== current && branch !== `origin/${current}`)
+  let latest = null
+  for (const b of releaseBranches) {
+    if (latest === null) latest = b
+    if (semver.gt(semverify(b), semverify(latest))) {
+      latest = b
+    }
+  }
+  return latest
+}
+
+const commitBeforeTag = async (commit, tag) => {
+  const gitArgs = ['tag', '--contains', commit]
+  const tagDetails = await GitProcess.exec(gitArgs, gitDir)
+  if (tagDetails.exitCode === 0) {
+    return tagDetails.stdout.split('\n').includes(tag)
+  }
+  throw GitProcess.parseError(tagDetails.stderr)
+}
+
+const getCommitsMergedIntoCurrentBranchSincePoint = async (point) => {
+  return getCommitsBetween(point, 'HEAD')
+}
+
+const getCommitsBetween = async (point1, point2) => {
+  const gitArgs = ['rev-list', `${point1}..${point2}`]
+  const commitsDetails = await GitProcess.exec(gitArgs, gitDir)
+  if (commitsDetails.exitCode !== 0) {
+    throw GitProcess.parseError(commitsDetails.stderr)
+  }
+  return commitsDetails.stdout.trim().split('\n')
+}
+
+const TITLE_PREFIX = 'Merged Pull Request: '
+
+const getCommitDetails = async (commitHash) => {
+  const commitInfo = await (await fetch(`https://github.com/electron/electron/branch_commits/${commitHash}`)).text()
+  const bits = commitInfo.split('</a>)')[0].split('>')
+  const prIdent = bits[bits.length - 1].trim()
+  if (!prIdent || commitInfo.indexOf('href="/electron/electron/pull') === -1) {
+    console.warn(`WARNING: Could not track commit "${commitHash}" to a pull request, it may have been committed directly to the branch`)
+    return null
+  }
+  const title = commitInfo.split('title="')[1].split('"')[0]
+  if (!title.startsWith(TITLE_PREFIX)) {
+    console.warn(`WARNING: Unknown PR title for commit "${commitHash}" in PR "${prIdent}"`)
+    return null
+  }
+  return {
+    mergedFrom: prIdent,
+    prTitle: entities.decode(title.substr(TITLE_PREFIX.length))
+  }
+}
+
+const doWork = async (items, fn, concurrent = 5) => {
+  const results = []
+  const toUse = [].concat(items)
+  let i = 1
+  const doBit = async () => {
+    if (toUse.length === 0) return
+    console.log(`Running ${i}/${items.length}`)
+    i += 1
+
+    const item = toUse.pop()
+    const index = toUse.length
+    results[index] = await fn(item)
+    await doBit()
+  }
+  const bits = []
+  for (let i = 0; i < concurrent; i += 1) {
+    bits.push(doBit())
+  }
+  await Promise.all(bits)
+  return results
+}
+
+const notes = new Map()
+
+const NoteType = {
+  FIX: 'fix',
+  FEATURE: 'feature',
+  BREAKING_CHANGE: 'breaking-change',
+  DOCUMENTATION: 'doc',
+  OTHER: 'other',
+  UNKNOWN: 'unknown'
+}
+
+class Note {
+  constructor (trueTitle, prNumber, ignoreIfInVersion) {
+    // Self bindings
+    this.guessType = this.guessType.bind(this)
+    this.fetchPrInfo = this.fetchPrInfo.bind(this)
+    this._getPr = this._getPr.bind(this)
+
+    if (!trueTitle.trim()) console.error(prNumber)
+
+    this._ignoreIfInVersion = ignoreIfInVersion
+    this.reverted = false
+    if (notes.has(trueTitle)) {
+      console.warn(`Duplicate PR trueTitle: "${trueTitle}", "${prNumber}" this might cause weird reversions (this would be RARE)`)
+    }
+
+    // Memoize
+    notes.set(trueTitle, this)
+
+    this.originalTitle = trueTitle
+    this.title = trueTitle
+    this.prNumber = prNumber
+    this.stripColon = true
+    if (this.guessType() !== NoteType.UNKNOWN && this.stripColon) {
+      this.title = trueTitle.split(':').slice(1).join(':').trim()
+    }
+  }
+
+  guessType () {
+    if (this.originalTitle.startsWith('fix:') ||
+        this.originalTitle.startsWith('Fix:')) return NoteType.FIX
+    if (this.originalTitle.startsWith('feat:')) return NoteType.FEATURE
+    if (this.originalTitle.startsWith('spec:') ||
+        this.originalTitle.startsWith('build:') ||
+        this.originalTitle.startsWith('test:') ||
+        this.originalTitle.startsWith('chore:') ||
+        this.originalTitle.startsWith('deps:') ||
+        this.originalTitle.startsWith('refactor:') ||
+        this.originalTitle.startsWith('tools:') ||
+        this.originalTitle.startsWith('vendor:') ||
+        this.originalTitle.startsWith('perf:') ||
+        this.originalTitle.startsWith('style:') ||
+        this.originalTitle.startsWith('ci')) return NoteType.OTHER
+    if (this.originalTitle.startsWith('doc:') ||
+        this.originalTitle.startsWith('docs:')) return NoteType.DOCUMENTATION
+
+    this.stripColon = false
+
+    if (this.pr && this.pr.data.labels.find(label => label.name === 'semver/breaking-change')) {
+      return NoteType.BREAKING_CHANGE
+    }
+    // FIXME: Backported features will not be picked up by this
+    if (this.pr && this.pr.data.labels.find(label => label.name === 'semver/nonbreaking-feature')) {
+      return NoteType.FEATURE
+    }
+
+    const n = this.prNumber.replace('#', '')
+    if (semanticMap.has(n)) {
+      switch (semanticMap.get(n)) {
+        case 'feat':
+          return NoteType.FEATURE
+        case 'fix':
+          return NoteType.FIX
+        case 'breaking-change':
+          return NoteType.BREAKING_CHANGE
+        case 'doc':
+          return NoteType.DOCUMENTATION
+        case 'build':
+        case 'vendor':
+        case 'refactor':
+        case 'spec':
+          return NoteType.OTHER
+        default:
+          throw new Error(`Unknown semantic mapping: ${semanticMap.get(n)}`)
+      }
+    }
+    return NoteType.UNKNOWN
+  }
+
+  async _getPr (n) {
+    const cachePath = path.resolve(CACHE_DIR, n)
+    if (fs.existsSync(cachePath)) {
+      return JSON.parse(fs.readFileSync(cachePath, 'utf8'))
+    } else {
+      try {
+        const pr = await github.pullRequests.get({
+          number: n,
+          owner: 'electron',
+          repo: 'electron'
+        })
+        fs.writeFileSync(cachePath, JSON.stringify({ data: pr.data }))
+        return pr
+      } catch (err) {
+        console.info('#### FAILED:', `#${n}`)
+        throw err
+      }
+    }
+  }
+
+  async fetchPrInfo () {
+    if (this.pr) return
+    const n = this.prNumber.replace('#', '')
+    this.pr = await this._getPr(n)
+    if (this.pr.data.labels.find(label => label.name === `merged/${this._ignoreIfInVersion.replace('origin/', '')}`)) {
+      // This means we probably backported this PR, let's try figure out what
+      // the corresponding backport PR would be by searching through comments
+      // for trop
+      let comments
+      const cacheCommentsPath = path.resolve(CACHE_DIR, `${n}-comments`)
+      if (fs.existsSync(cacheCommentsPath)) {
+        comments = JSON.parse(fs.readFileSync(cacheCommentsPath, 'utf8'))
+      } else {
+        comments = await github.issues.getComments({
+          number: n,
+          owner: 'electron',
+          repo: 'electron',
+          per_page: 100
+        })
+        fs.writeFileSync(cacheCommentsPath, JSON.stringify({ data: comments.data }))
+      }
+
+      const tropComment = comments.data.find(
+        c => (
+          new RegExp(`We have automatically backported this PR to "${this._ignoreIfInVersion.replace('origin/', '')}", please check out #[0-9]+`)
+        ).test(c.body)
+      )
+
+      if (tropComment) {
+        const commentBits = tropComment.body.split('#')
+        const tropPrNumber = commentBits[commentBits.length - 1]
+
+        const tropPr = await this._getPr(tropPrNumber)
+        if (tropPr.data.merged && tropPr.data.merge_commit_sha) {
+          if (await commitBeforeTag(tropPr.data.merge_commit_sha, await getLastKnownReleaseOnBranch(this._ignoreIfInVersion))) {
+            this.reverted = true
+            console.log('PR', this.prNumber, 'was backported to a previous version, ignoring from notes')
+          }
+        }
+      }
+    }
+  }
+}
+
+Note.findByTrueTitle = (trueTitle) => notes.get(trueTitle)
+
+class ReleaseNotes {
+  constructor (ignoreIfInVersion) {
+    this._ignoreIfInVersion = ignoreIfInVersion
+    this._handledPrs = new Set()
+    this._revertedPrs = new Set()
+    this.other = []
+    this.docs = []
+    this.fixes = []
+    this.features = []
+    this.breakingChanges = []
+    this.unknown = []
+  }
+
+  async parseCommits (commitHashes) {
+    await doWork(commitHashes, async (commit) => {
+      const info = await getCommitDetails(commit)
+      if (!info) return
+      // Only handle each PR once
+      if (this._handledPrs.has(info.mergedFrom)) return
+      this._handledPrs.add(info.mergedFrom)
+
+      // Strip the trop backport prefix
+      const trueTitle = info.prTitle.replace(/^Backport \([0-9]+-[0-9]+-x\) - /, '')
+      if (this._revertedPrs.has(trueTitle)) return
+
+      // Handle PRs that revert other PRs
+      if (trueTitle.startsWith('Revert "')) {
+        const revertedTrueTitle = trueTitle.substr(8, trueTitle.length - 9)
+        this._revertedPrs.add(revertedTrueTitle)
+        const existingNote = Note.findByTrueTitle(revertedTrueTitle)
+        if (existingNote) {
+          existingNote.reverted = true
+        }
+        return
+      }
+
+      // Add a note for this PR
+      const note = new Note(trueTitle, info.mergedFrom, this._ignoreIfInVersion)
+      try {
+        await note.fetchPrInfo()
+      } catch (err) {
+        console.error(commit, info)
+        throw err
+      }
+      switch (note.guessType()) {
+        case NoteType.FIX:
+          this.fixes.push(note)
+          break
+        case NoteType.FEATURE:
+          this.features.push(note)
+          break
+        case NoteType.BREAKING_CHANGE:
+          this.breakingChanges.push(note)
+          break
+        case NoteType.OTHER:
+          this.other.push(note)
+          break
+        case NoteType.DOCUMENTATION:
+          this.docs.push(note)
+          break
+        case NoteType.UNKNOWN:
+        default:
+          this.unknown.push(note)
+          break
+      }
+    }, 20)
+  }
+
+  list (notes) {
+    if (notes.length === 0) {
+      return '_There are no items in this section this release_'
+    }
+    return notes
+      .filter(note => !note.reverted)
+      .sort((a, b) => a.title.toLowerCase().localeCompare(b.title.toLowerCase()))
+      .map((note) => `* ${note.title.trim()} ${note.prNumber}`).join('\n')
+  }
+
+  render () {
+    return `
+# Release Notes
+
+## Breaking Changes
+
+${this.list(this.breakingChanges)}
+
+## Features
+
+${this.list(this.features)}
+
+## Fixes
+
+${this.list(this.fixes)}
+
+## Other Changes (E.g. Internal refactors or build system updates)
+
+${this.list(this.other)}
+
+## Documentation Updates
+
+Some documentation updates, fixes and reworks: ${
+  this.docs.length === 0
+    ? '_None in this release_'
+    : this.docs.sort((a, b) => a.prNumber.localeCompare(b.prNumber)).map(note => note.prNumber).join(', ')
+}
+${this.unknown.filter(n => !n.reverted).length > 0
+? `## Unknown (fix these before publishing release)
+
+${this.list(this.unknown)}
+` : ''}`
+  }
+}
+
+async function main () {
+  if (!fs.existsSync(CACHE_DIR)) {
+    fs.mkdirSync(CACHE_DIR)
+  }
+  const lastReleaseBranch = await getLastReleaseBranch()
+
+  const notes = new ReleaseNotes(lastReleaseBranch)
+  const lastKnownReleaseInCurrentStream = await getLastKnownReleaseOnBranch(await getCurrentBranch())
+  const currentBranchOff = await getBranchOffPoint(await getCurrentBranch())
+
+  const commits = await getCommitsMergedIntoCurrentBranchSincePoint(
+    lastKnownReleaseInCurrentStream || currentBranchOff
+  )
+
+  if (!lastKnownReleaseInCurrentStream) {
+    // This means we are the first release in our stream
+    // FIXME: This will not work for minor releases!!!!
+
+    const lastReleaseBranch = await getLastReleaseBranch()
+    const lastBranchOff = await getBranchOffPoint(lastReleaseBranch)
+    commits.push(...await getCommitsBetween(lastBranchOff, currentBranchOff))
+  }
+
+  await notes.parseCommits(commits)
+
+  console.log(notes.render())
+
+  const badNotes = notes.unknown.filter(n => !n.reverted).length
+  if (badNotes > 0) {
+    throw new Error(`You have ${badNotes.length} unknown release notes, please fix them before releasing`)
+  }
+}
+
+if (process.mainModule === module) {
+  main().catch((err) => {
+    console.error('Error Occurred:', err)
+    process.exit(1)
+  })
+}

+ 193 - 0
script/release-notes/legacy-pr-semantic-map.csv

@@ -0,0 +1,193 @@
+12884,fix
+12093,feat
+12595,doc
+12674,doc
+12577,doc
+12084,doc
+12103,doc
+12948,build
+12496,feat
+13133,build
+12651,build
+12767,doc
+12238,build
+12646,build
+12373,doc
+12723,feat
+12202,doc
+12504,doc
+12669,doc
+13044,feat
+12746,spec
+12617,doc
+12532,feat
+12619,feat
+12118,build
+12921,build
+13281,doc
+12059,feat
+12131,doc
+12123,doc
+12080,build
+12904,fix
+12562,fix
+12122,spec
+12817,spec
+12254,fix
+12999,vendor
+13248,vendor
+12104,build
+12477,feat
+12648,refactor
+12649,refactor
+12650,refactor
+12673,refactor
+12305,refactor
+12168,refactor
+12627,refactor
+12446,doc
+12304,refactor
+12615,breaking-change
+12135,feat
+12155,doc
+12975,fix
+12501,fix
+13065,fix
+13089,build
+12786,doc
+12736,doc
+11966,doc
+12885,fix
+12984,refactor
+12187,build
+12535,refactor
+12538,feat
+12190,fix
+12139,fix
+11328,fix
+12828,feat
+12614,feat
+12546,feat
+12647,refactor
+12987,build
+12900,doc
+12389,doc
+12387,doc
+12232,doc
+12742,build
+12043,fix
+12741,fix
+12995,fix
+12395,fix
+12003,build
+12216,fix
+12132,fix
+12062,fix
+12968,doc
+12422,doc
+12149,doc
+13339,build
+12044,fix
+12327,fix
+12180,fix
+12263,spec
+12153,spec
+13055,feat
+12113,doc
+12067,doc
+12882,build
+13029,build
+13067,doc
+12196,build
+12797,doc
+12013,fix
+12507,fix
+11607,feat
+12837,build
+11613,feat
+12015,spec
+12058,doc
+12403,spec
+12192,feat
+12204,doc
+13294,doc
+12542,doc
+12826,refactor
+12781,doc
+12157,fix
+12319,fix
+12188,build
+12399,doc
+12145,doc
+12661,refactor
+8953,fix
+12037,fix
+12186,spec
+12397,fix
+12040,doc
+12886,refactor
+12008,refactor
+12716,refactor
+12750,refactor
+12787,refactor
+12858,refactor
+12140,refactor
+12503,refactor
+12514,refactor
+12584,refactor
+12596,refactor
+12637,refactor
+12660,refactor
+12696,refactor
+12877,refactor
+13030,refactor
+12916,build
+12896,build
+13039,breaking-change
+11927,build
+12847,doc
+12852,doc
+12194,fix
+12870,doc
+12924,fix
+12682,doc
+12004,refactor
+12601,refactor
+12998,fix
+13105,vendor
+12452,doc
+12738,fix
+12536,refactor
+12189,spec
+13122,spec
+12662,fix
+12665,doc
+12419,feat
+12756,doc
+12616,refactor
+12679,breaking-change
+12000,doc
+12372,build
+12805,build
+12348,fix
+12315,doc
+12072,doc
+12912,doc
+12982,fix
+12105,doc
+12917,spec
+12400,doc
+12101,feat
+12642,build
+13058,fix
+12913,vendor
+13298,vendor
+13042,build
+11230,feat
+11459,feat
+12476,vendor
+11937,doc
+12328,build
+12539,refactor
+12127,build
+12537,build

+ 11 - 18
script/release.js

@@ -1,8 +1,8 @@
 #!/usr/bin/env node
 
+if (!process.env.CI) require('dotenv-safe').load()
 require('colors')
 const args = require('minimist')(process.argv.slice(2))
-const assert = require('assert')
 const fs = require('fs')
 const { execSync } = require('child_process')
 const GitHub = require('github')
@@ -16,17 +16,16 @@ const fail = '\u2717'.red
 const sumchecker = require('sumchecker')
 const temp = require('temp').track()
 const { URL } = require('url')
+const targetRepo = pkgVersion.indexOf('nightly') > 0 ? 'nightlies' : 'electron'
 let failureCount = 0
 
-assert(process.env.ELECTRON_GITHUB_TOKEN, 'ELECTRON_GITHUB_TOKEN not found in environment')
-
 const github = new GitHub({
   followRedirects: false
 })
 github.authenticate({type: 'token', token: process.env.ELECTRON_GITHUB_TOKEN})
 
 async function getDraftRelease (version, skipValidation) {
-  let releaseInfo = await github.repos.getReleases({owner: 'electron', repo: 'electron'})
+  let releaseInfo = await github.repos.getReleases({owner: 'electron', repo: targetRepo})
   let drafts
   let versionToCheck
   if (version) {
@@ -90,15 +89,12 @@ function assetsForVersion (version, validatingRelease) {
     `electron-${version}-darwin-x64-dsym.zip`,
     `electron-${version}-darwin-x64-symbols.zip`,
     `electron-${version}-darwin-x64.zip`,
-    `electron-${version}-linux-arm-symbols.zip`,
-    `electron-${version}-linux-arm.zip`,
     `electron-${version}-linux-arm64-symbols.zip`,
     `electron-${version}-linux-arm64.zip`,
     `electron-${version}-linux-armv7l-symbols.zip`,
     `electron-${version}-linux-armv7l.zip`,
     `electron-${version}-linux-ia32-symbols.zip`,
     `electron-${version}-linux-ia32.zip`,
-//    `electron-${version}-linux-mips64el.zip`,
     `electron-${version}-linux-x64-symbols.zip`,
     `electron-${version}-linux-x64.zip`,
     `electron-${version}-mas-x64-dsym.zip`,
@@ -113,11 +109,9 @@ function assetsForVersion (version, validatingRelease) {
     `electron-api.json`,
     `electron.d.ts`,
     `ffmpeg-${version}-darwin-x64.zip`,
-    `ffmpeg-${version}-linux-arm.zip`,
     `ffmpeg-${version}-linux-arm64.zip`,
     `ffmpeg-${version}-linux-armv7l.zip`,
     `ffmpeg-${version}-linux-ia32.zip`,
-//    `ffmpeg-${version}-linux-mips64el.zip`,
     `ffmpeg-${version}-linux-x64.zip`,
     `ffmpeg-${version}-mas-x64.zip`,
     `ffmpeg-${version}-win32-ia32.zip`,
@@ -147,6 +141,8 @@ function s3UrlsForVersion (version) {
 }
 
 function checkVersion () {
+  if (args.skipVersionCheck) return
+
   console.log(`Verifying that app version matches package version ${pkgVersion}.`)
   let startScript = path.join(__dirname, 'start.py')
   let scriptArgs = ['--version']
@@ -184,11 +180,7 @@ function uploadNodeShasums () {
 function uploadIndexJson () {
   console.log('Uploading index.json to S3.')
   let scriptPath = path.join(__dirname, 'upload-index-json.py')
-  let scriptArgs = []
-  if (args.automaticRelease) {
-    scriptArgs.push('-R')
-  }
-  runScript(scriptPath, scriptArgs)
+  runScript(scriptPath, [pkgVersion])
   console.log(`${pass} Done uploading index.json to S3.`)
 }
 
@@ -199,7 +191,7 @@ async function createReleaseShasums (release) {
     console.log(`${fileName} already exists on GitHub; deleting before creating new file.`)
     await github.repos.deleteAsset({
       owner: 'electron',
-      repo: 'electron',
+      repo: targetRepo,
       id: existingAssets[0].id
     }).catch(err => {
       console.log(`${fail} Error deleting ${fileName} on GitHub:`, err)
@@ -218,7 +210,7 @@ async function createReleaseShasums (release) {
 async function uploadShasumFile (filePath, fileName, release) {
   let githubOpts = {
     owner: 'electron',
-    repo: 'electron',
+    repo: targetRepo,
     id: release.id,
     filePath,
     name: fileName
@@ -253,7 +245,7 @@ function saveShaSumFile (checksums, fileName) {
 async function publishRelease (release) {
   let githubOpts = {
     owner: 'electron',
-    repo: 'electron',
+    repo: targetRepo,
     id: release.id,
     tag_name: release.tag_name,
     draft: false
@@ -280,6 +272,7 @@ async function makeRelease (releaseToValidate) {
     let draftRelease = await getDraftRelease()
     uploadNodeShasums()
     uploadIndexJson()
+
     await createReleaseShasums(draftRelease)
     // Fetch latest version of release before verifying
     draftRelease = await getDraftRelease(pkgVersion, true)
@@ -307,7 +300,7 @@ async function verifyAssets (release) {
   let downloadDir = await makeTempDir()
   let githubOpts = {
     owner: 'electron',
-    repo: 'electron',
+    repo: targetRepo,
     headers: {
       Accept: 'application/octet-stream'
     }

+ 8 - 3
script/upload-to-github.js

@@ -1,18 +1,23 @@
+if (!process.env.CI) require('dotenv-safe').load()
+
 const GitHub = require('github')
 const github = new GitHub()
 github.authenticate({type: 'token', token: process.env.ELECTRON_GITHUB_TOKEN})
 
-if (process.argv.length < 5) {
+if (process.argv.length < 6) {
   console.log('Usage: upload-to-github filePath fileName releaseId')
   process.exit(1)
 }
 let filePath = process.argv[2]
 let fileName = process.argv[3]
 let releaseId = process.argv[4]
+let releaseVersion = process.argv[5]
+
+const targetRepo = releaseVersion.indexOf('nightly') > 0 ? 'nightlies' : 'electron'
 
 let githubOpts = {
   owner: 'electron',
-  repo: 'electron',
+  repo: targetRepo,
   id: releaseId,
   filePath: filePath,
   name: fileName
@@ -34,7 +39,7 @@ function uploadToGitHub () {
           console.log(`${fileName} already exists; will delete before retrying upload.`)
           github.repos.deleteAsset({
             owner: 'electron',
-            repo: 'electron',
+            repo: targetRepo,
             id: existingAssets[0].id
           }).then(uploadToGitHub).catch(uploadToGitHub)
         } else {