diff --git a/.gitignore b/.gitignore index 37962447..e9b04dc7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.asc *.egg-info/ *.log *.pyc diff --git a/release/README.md b/release/README.md new file mode 100644 index 00000000..3b81d532 --- /dev/null +++ b/release/README.md @@ -0,0 +1,2 @@ +These are helper tools for managing official releases. +See the [release process](../docs/release-process.md) document for more details. diff --git a/release/sign-launcher.py b/release/sign-launcher.py new file mode 100755 index 00000000..ba5e490c --- /dev/null +++ b/release/sign-launcher.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# Copyright (C) 2020 The Android Open Source Project +# +# 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. + +"""Helper tool for signing repo launcher scripts correctly. + +This is intended to be run only by the official Repo release managers. +""" + +import argparse +import os +import subprocess +import sys + +import util + + +def sign(opts): + """Sign the launcher!""" + output = '' + for key in opts.keys: + # We use ! at the end of the key so that gpg uses this specific key. + # Otherwise it uses the key as a lookup into the overall key and uses the + # default signing key. i.e. It will see that KEYID_RSA is a subkey of + # another key, and use the primary key to sign instead of the subkey. + cmd = ['gpg', '--homedir', opts.gpgdir, '-u', f'{key}!', '--batch', '--yes', + '--armor', '--detach-sign', '--output', '-', opts.launcher] + ret = util.run(opts, cmd, encoding='utf-8', stdout=subprocess.PIPE) + output += ret.stdout + + # Save the combined signatures into one file. + with open(f'{opts.launcher}.asc', 'w', encoding='utf-8') as fp: + fp.write(output) + + +def check(opts): + """Check the signature.""" + util.run(opts, ['gpg', '--verify', f'{opts.launcher}.asc']) + + +def postmsg(opts): + """Helpful info to show at the end for release manager.""" + print(f""" +Repo launcher bucket: + gs://git-repo-downloads/ + +To upload this launcher directly: + gsutil cp -a public-read {opts.launcher} {opts.launcher}.asc gs://git-repo-downloads/ + +NB: You probably want to upload it with a specific version first, e.g.: + gsutil cp -a public-read {opts.launcher} gs://git-repo-downloads/repo-3.0 + gsutil cp -a public-read {opts.launcher}.asc gs://git-repo-downloads/repo-3.0.asc +""") + + +def get_parser(): + """Get a CLI parser.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('-n', '--dry-run', + dest='dryrun', action='store_true', + help='show everything that would be done') + parser.add_argument('--gpgdir', + default=os.path.join(util.HOMEDIR, '.gnupg', 'repo'), + help='path to dedicated gpg dir with release keys ' + '(default: ~/.gnupg/repo/)') + parser.add_argument('--keyid', dest='keys', default=[], action='append', + help='alternative signing keys to use') + parser.add_argument('launcher', + default=os.path.join(util.TOPDIR, 'repo'), nargs='?', + help='the launcher script to sign') + return parser + + +def main(argv): + """The main func!""" + parser = get_parser() + opts = parser.parse_args(argv) + + if not os.path.exists(opts.gpgdir): + parser.error(f'--gpgdir does not exist: {opts.gpgdir}') + if not os.path.exists(opts.launcher): + parser.error(f'launcher does not exist: {opts.launcher}') + + opts.launcher = os.path.relpath(opts.launcher) + print(f'Signing "{opts.launcher}" launcher script and saving to ' + f'"{opts.launcher}.asc"') + + if opts.keys: + print(f'Using custom keys to sign: {" ".join(opts.keys)}') + else: + print('Using official Repo release keys to sign') + opts.keys = [util.KEYID_DSA, util.KEYID_RSA, util.KEYID_ECC] + util.import_release_key(opts) + + sign(opts) + check(opts) + postmsg(opts) + + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/release/sign-tag.py b/release/sign-tag.py new file mode 100755 index 00000000..7b4b4cab --- /dev/null +++ b/release/sign-tag.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# Copyright (C) 2020 The Android Open Source Project +# +# 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. + +"""Helper tool for signing repo release tags correctly. + +This is intended to be run only by the official Repo release managers. +""" + +import argparse +import os +import re +import subprocess +import sys + +import util + + +# We currently sign with the old DSA key as it's been around the longest. +# We should transition to RSA by Jun 2020, and ECC by Jun 2021. +KEYID = util.KEYID_DSA + +# Regular expression to validate tag names. +RE_VALID_TAG = r'^v([0-9]+[.])+[0-9]+$' + + +def sign(opts): + """Tag the commit & sign it!""" + # We use ! at the end of the key so that gpg uses this specific key. + # Otherwise it uses the key as a lookup into the overall key and uses the + # default signing key. i.e. It will see that KEYID_RSA is a subkey of + # another key, and use the primary key to sign instead of the subkey. + cmd = ['git', 'tag', '-s', opts.tag, '-u', f'{opts.key}!', + '-m', f'repo {opts.tag}', opts.commit] + + key = 'GNUPGHOME' + print('+', f'export {key}="{opts.gpgdir}"') + oldvalue = os.getenv(key) + os.putenv(key, opts.gpgdir) + util.run(opts, cmd) + if oldvalue is None: + os.unsetenv(key) + else: + os.putenv(key, oldvalue) + + +def check(opts): + """Check the signature.""" + util.run(opts, ['git', 'tag', '--verify', opts.tag]) + + +def postmsg(opts): + """Helpful info to show at the end for release manager.""" + cmd = ['git', 'rev-parse', 'remotes/origin/stable'] + ret = util.run(opts, cmd, encoding='utf-8', stdout=subprocess.PIPE) + current_release = ret.stdout.strip() + + cmd = ['git', 'log', '--format=%h (%aN) %s', '--no-merges', + f'remotes/origin/stable..{opts.tag}'] + ret = util.run(opts, cmd, encoding='utf-8', stdout=subprocess.PIPE) + shortlog = ret.stdout.strip() + + print(f""" +Here's the short log since the last release. +{shortlog} + +To push release to the public: + git push origin {opts.commit}:stable {opts.tag} -n +NB: People will start upgrading to this version immediately. + +To roll back a release: + git push origin --force {current_release}:stable -n +""") + + +def get_parser(): + """Get a CLI parser.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('-n', '--dry-run', + dest='dryrun', action='store_true', + help='show everything that would be done') + parser.add_argument('--gpgdir', + default=os.path.join(util.HOMEDIR, '.gnupg', 'repo'), + help='path to dedicated gpg dir with release keys ' + '(default: ~/.gnupg/repo/)') + parser.add_argument('-f', '--force', action='store_true', + help='force signing of any tag') + parser.add_argument('--keyid', dest='key', + help='alternative signing key to use') + parser.add_argument('tag', + help='the tag to create (e.g. "v2.0")') + parser.add_argument('commit', default='HEAD', nargs='?', + help='the commit to tag') + return parser + + +def main(argv): + """The main func!""" + parser = get_parser() + opts = parser.parse_args(argv) + + if not os.path.exists(opts.gpgdir): + parser.error(f'--gpgdir does not exist: {opts.gpgdir}') + + if not opts.force and not re.match(RE_VALID_TAG, opts.tag): + parser.error(f'tag "{opts.tag}" does not match regex "{RE_VALID_TAG}"; ' + 'use --force to sign anyways') + + if opts.key: + print(f'Using custom key to sign: {opts.key}') + else: + print('Using official Repo release key to sign') + opts.key = KEYID + util.import_release_key(opts) + + sign(opts) + check(opts) + postmsg(opts) + + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/release/util.py b/release/util.py new file mode 100644 index 00000000..9d0eb1dc --- /dev/null +++ b/release/util.py @@ -0,0 +1,73 @@ +# Copyright (C) 2020 The Android Open Source Project +# +# 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. + +"""Random utility code for release tools.""" + +import os +import re +import subprocess +import sys + + +assert sys.version_info >= (3, 6), 'This module requires Python 3.6+' + + +TOPDIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HOMEDIR = os.path.expanduser('~') + + +# These are the release keys we sign with. +KEYID_DSA = '8BB9AD793E8E6153AF0F9A4416530D5E920F5C65' +KEYID_RSA = 'A34A13BE8E76BFF46A0C022DA2E75A824AAB9624' +KEYID_ECC = 'E1F9040D7A3F6DAFAC897CD3D3B95DA243E48A39' + + +def cmdstr(cmd): + """Get a nicely quoted shell command.""" + ret = [] + for arg in cmd: + if not re.match(r'^[a-zA-Z0-9/_.=-]+$', arg): + arg = f'"{arg}"' + ret.append(arg) + return ' '.join(ret) + + +def run(opts, cmd, check=True, **kwargs): + """Helper around subprocess.run to include logging.""" + print('+', cmdstr(cmd)) + if opts.dryrun: + cmd = ['true', '--'] + cmd + try: + return subprocess.run(cmd, check=check, **kwargs) + except subprocess.CalledProcessError as e: + print(f'aborting: {e}', file=sys.stderr) + sys.exit(1) + + +def import_release_key(opts): + """Import the public key of the official release repo signing key.""" + # Extract the key from our repo launcher. + launcher = getattr(opts, 'launcher', os.path.join(TOPDIR, 'repo')) + print(f'Importing keys from "{launcher}" launcher script') + with open(launcher, encoding='utf-8') as fp: + data = fp.read() + + keys = re.findall( + r'\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n[^-]*' + r'\n-----END PGP PUBLIC KEY BLOCK-----\n', data, flags=re.M) + run(opts, ['gpg', '--import'], input='\n'.join(keys).encode('utf-8')) + + print('Marking keys as fully trusted') + run(opts, ['gpg', '--import-ownertrust'], + input=f'{KEYID_DSA}:6:\n'.encode('utf-8'))