Add script to generate release notes

The script uses assemble_changelog.py to generate the bulk of the content,
and adds some other headers, etc to create a standardised release note.

The output includes placeholders for the sha256 hashes of the zip and the
tarball which must be updated by hand.

The script displays a checklist, which, when followed, results in a note
that satisfies the release processes.

Signed-off-by: Dave Rodgman <dave.rodgman@arm.com>
diff --git a/scripts/generate_release_notes.py b/scripts/generate_release_notes.py
new file mode 100755
index 0000000..9e9129c
--- /dev/null
+++ b/scripts/generate_release_notes.py
@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+"""This script generates the MbedTLS release notes in markdown format.
+
+It does this by calling assemble_changelog.py to generate the bulk of
+content, and also inserting other content such as a brief description,
+hashes for the tar and zip files containing the release, etc.
+
+Returns 0 on success, 1 on failure.
+
+Note: must be run from Mbed TLS root."""
+
+# Copyright (c) 2020, Arm Limited, All Rights Reserved
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# This file is part of Mbed TLS (https://tls.mbed.org)
+
+import re
+import sys
+import os.path
+import hashlib
+import argparse
+import tempfile
+import subprocess
+
+TEMPLATE = """## Description
+
+These are the release notes for MbedTLS version {version}.
+
+{description}
+
+{changelog}
+
+## Who should update
+
+{whoshouldupdate}
+
+## Checksum
+
+The SHA256 hashes for the archives are:
+
+```
+{tarhash}  mbedtls-{version}.tar.gz
+{ziphash}  mbedtls-{version}.zip
+```
+"""
+
+WHO_SHOULD_UPDATE_DEFAULT = 'We recommend all affected users should \
+update to take advantage of the bug fixes contained in this release at \
+an appropriate point in their development lifecycle.'
+
+
+CHECKLIST = '''Please review the release notes to ensure that all of the \
+following are documented (if needed):
+- Missing functionality
+- Changes in functionality
+- Known issues
+'''
+
+
+CUSTOM_WORDS = 'Hellman API APIs gz lifecycle Bugfix CMake inlined Crypto endian SHA xxx'
+
+
+def sha256_digest(filename):
+    """Read given file and return a SHA256 digest"""
+    h = hashlib.sha256()
+    with open(filename, 'rb') as f:
+        h.update(f.read())
+    return h.hexdigest()
+
+
+def error(text):
+    """Display error message and exit"""
+    print(f'ERROR: {text}')
+    sys.exit(1)
+
+
+def warn(text):
+    """Display warning message"""
+    print(f'WARNING: {text}')
+
+
+def generate_content(args):
+    """Return template populated with given content"""
+    for field in ('version', 'tarhash', 'ziphash', 'changelog',
+                  'description', 'whoshouldupdate'):
+        if not field in args:
+            error(f'{field} not specified')
+    return TEMPLATE.format(**args)
+
+
+def run_cmd(cmd, capture=True):
+    """Run given command in a shell and return the command output"""
+    # Note: [:-1] strips the trailing newline introduced by the shell.
+    if capture:
+        return subprocess.check_output(cmd, shell=True, input=None,
+                                   universal_newlines=True)[:-1]
+    else:
+        subprocess.call(cmd, shell=True)
+
+
+def parse_args(args):
+    """Parse command line arguments and return cleaned up args"""
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('-o', '--output', default='ReleaseNotes.md',
+                        help='Output file (defaults to ReleaseNotes.md)')
+    parser.add_argument('-t', '--tar', action='store',
+                        help='Optional tar containing release (to generate hash)')
+    parser.add_argument('-z', '--zip', action='store',
+                        help='Optional zip containing release (to generate hash)')
+    parser.add_argument('-d', '--description', action='store', required=True,
+                        help='Short description of release (or name of file containing this)')
+    parser.add_argument('-w', '--who', action='store', default=WHO_SHOULD_UPDATE_DEFAULT,
+                        help='Optional short description of who should \
+                            update (or name of file containing this)')
+    args = parser.parse_args(args)
+
+    # If these exist as files, interpret as files containing
+    # desired content rather than literal content.
+    for field in ('description', 'who'):
+        if os.path.exists(getattr(args, field)):
+            with open(getattr(args, field), 'r') as f:
+                setattr(args, field, f.read())
+
+    return args
+
+
+def spellcheck(text):
+    with tempfile.NamedTemporaryFile() as temp_file:
+        with open(temp_file.name, 'w') as f:
+            f.write(text)
+        result = run_cmd(f'ispell -d american -w _- -a < {temp_file.name}')
+        input_lines = text.splitlines()
+        ispell_re = re.compile(r'& (\S+) \d+ \d+:.*')
+        bad_words = set()
+        bad_lines = set()
+        line_no = 1
+        for l in result.splitlines():
+            if l.strip() == '':
+                line_no += 1
+            elif l.startswith('&'):
+                m = ispell_re.fullmatch(l)
+                word = m.group(1)
+                if word.isupper():
+                    # ignore all-uppercase words
+                    pass
+                elif "_" in word:
+                    # part of a non-English 'word' like PSA_CRYPTO_ECC
+                    pass
+                elif word.startswith('-'):
+                    # ignore flags
+                    pass
+                elif word in CUSTOM_WORDS:
+                    # accept known-good words
+                    pass
+                else:
+                    bad_words.add(word)
+                    bad_lines.add(line_no)
+        if bad_words:
+            bad_lines = '\n'.join('    ' + input_lines[n] for n in sorted(bad_lines))
+            bad_words = ', '.join(bad_words)
+            warn('Release notes contain the following mis-spelled ' \
+                        f'words: {bad_words}:\n{bad_lines}\n')
+
+
+def gen_rel_notes(args):
+    """Return release note content from given command line args"""
+    # Get version by parsing version.h. Assumption is that bump_version
+    # has been run and this contains the correct version number.
+    version = run_cmd('cat include/mbedtls/version.h | \
+        clang -Iinclude -dM -E - | grep "MBEDTLS_VERSION_STRING "')
+    version = version.split()[-1][1:-1]
+
+    # Get main changelog content.
+    assemble_path = os.path.join(os.getcwd(), 'scripts', 'assemble_changelog.py')
+    with tempfile.NamedTemporaryFile() as temp_file:
+        run_cmd(f'{assemble_path} -o {temp_file.name} --latest-only')
+        with open(temp_file.name) as f:
+            changelog = f.read()
+
+    arg_hash = {
+        'version': version,
+        'tarhash': '',
+        'ziphash': '',
+        'changelog': changelog.strip(),
+        'description': args.description.strip(),
+        'whoshouldupdate': args.who.strip()
+    }
+
+    spellcheck(generate_content(arg_hash))
+
+    arg_hash['tarhash'] = sha256_digest(args.tar) if args.tar else "x" * 64
+    arg_hash['ziphash'] = sha256_digest(args.zip) if args.zip else "x" * 64
+    return generate_content(arg_hash)
+
+
+def main():
+    # Very basic check to see if we are in the root.
+    path = os.path.join(os.getcwd(), 'scripts', 'generate_release_notes.py')
+    if not os.path.exists(path):
+        error(f'{sys.argv[0]} must be run from the mbedtls root')
+
+    args = parse_args(sys.argv[1:])
+
+    content = gen_rel_notes(args)
+    with open(args.output, 'w') as f:
+        f.write(content)
+
+    print(CHECKLIST)
+
+
+if __name__ == '__main__':
+    main()