hacking/release-announcement.py
5fb416ae
 #!/usr/bin/env python3
 # coding: utf-8
 # Copyright: (c) 2019, Ansible Project
 # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
 
 # Make coding more python3-ish
 from __future__ import (absolute_import, division, print_function)
 __metaclass__ = type
 
 
 import argparse
 import asyncio
 import datetime
 import hashlib
 import sys
 from collections import UserString
 from distutils.version import LooseVersion
 
 import aiohttp
 from jinja2 import Environment, DictLoader
 
 # pylint: disable=
 VERSION_FRAGMENT = """
 {%- if versions | length > 1 %}
   {% for version in versions %}
     {% if loop.last %}and {{ version }}{% else %}
       {% if versions | length == 2 %}{{ version }} {% else %}{{ version }}, {% endif -%}
     {% endif -%}
   {% endfor -%}
 {%- else %}{{ versions[0] }}{% endif -%}
 """
 
 LONG_TEMPLATE = """
 {% set plural = True if versions | length == 1 else False %}
 {% set latest_ver = (versions | sort(attribute='ver_obj'))[-1] %}
 
 To: ansible-devel@googlegroups.com, ansible-project@googlegroups.com, ansible-announce@googlegroups.com
 Subject: New Ansible release{% if plural %}s{% endif %} {{ version_str }}
 
 {% filter wordwrap %}
 Hi all- we're happy to announce that the general release of Ansible {{ version_str }}{% if plural %} are{%- else %} is{%- endif %} now available!
 {% endfilter %}
 
 
 
 How do you get it?
 ------------------
 
 {% for version in versions %}
 $ pip install ansible=={{ version }} --user
 {% if not loop.last %}
 or
 {% endif %}
 {% endfor %}
 
 The tar.gz of the release{% if plural %}s{% endif %} can be found here:
 
 {% for version in versions %}
 * {{ version }}
   https://releases.ansible.com/ansible/ansible-{{ version }}.tar.gz
   SHA256: {{ hashes[version] }}
 {% endfor %}
 
 
 What's new in {{ version_str }}
 {{ '-' * (14 + version_str | length) }}
 
 {% filter wordwrap %}
 {% if plural %}This release is a{% else %}These releases are{% endif %} maintenance release{% if plural %}s{% endif %} containing numerous bugfixes. The full {% if versions | length <= 1 %} changelog is{% else %} changelogs are{% endif %} at:
 {% endfilter %}
 
 
 {% for version in versions %}
 * {{ version }}
   https://github.com/ansible/ansible/blob/stable-{{ version.split('.')[:2] | join('.') }}/changelogs/CHANGELOG-v{{ version.split('.')[:2] | join('.') }}.rst
 {% endfor %}
 
 
 What's the schedule for future maintenance releases?
 ----------------------------------------------------
 
 {% filter wordwrap %}
 Future maintenance releases will occur approximately every 3 weeks.  So expect the next one around {{ next_release.strftime('%Y-%m-%d') }}.
 {% endfilter %}
 
 
 
 Porting Help
 ------------
 
 {% filter wordwrap %}
 We've published a porting guide at
 https://docs.ansible.com/ansible/devel/porting_guides/porting_guide_{{ latest_ver.split('.')[:2] | join('.') }}.html to help migrate your content to {{ latest_ver.split('.')[:2] | join('.') }}.
 {% endfilter %}
 
 
 
 {% filter wordwrap %}
8dd46d6f
 If you discover any errors or if any of your working playbooks break when you upgrade to {{ latest_ver }}, please use the following link to report the regression:
 {% endfilter %}
 
 
   https://github.com/ansible/ansible/issues/new/choose
 
 {% filter wordwrap %}
 In your issue, be sure to mention the Ansible version that works and the one that doesn't.
5fb416ae
 {% endfilter %}
 
 
 Thanks!
 
 -{{ name }}
 
 """  # noqa for E501 (line length).
 # jinja2 is horrid about getting rid of extra newlines so we have to have a single per paragraph for
 # proper wrapping to occur
 
 SHORT_TEMPLATE = """
 @ansible
 {{ version_str }}
 {% if versions | length > 1 %}
   have
 {% else %}
   has
 {% endif %}
 been released! Get
 {% if versions | length > 1 %}
 them
 {% else %}
 it
 {% endif %}
 on PyPI: pip install ansible=={{ (versions|sort(attribute='ver_obj'))[-1] }},
 https://releases.ansible.com/ansible/, the Ansible PPA on Launchpad, or GitHub.  Happy automating!
 """  # noqa for E501 (line length).
 # jinja2 is horrid about getting rid of extra newlines so we have to have a single per paragraph for
 # proper wrapping to occur
 
 JINJA_ENV = Environment(
     loader=DictLoader({'long': LONG_TEMPLATE,
                        'short': SHORT_TEMPLATE,
                        'version_string': VERSION_FRAGMENT,
                        }),
     extensions=['jinja2.ext.i18n'],
     trim_blocks=True,
     lstrip_blocks=True,
 )
 
 
 class VersionStr(UserString):
     def __init__(self, string):
         super().__init__(string.strip())
         self.ver_obj = LooseVersion(string)
 
 
 def parse_args(args):
     parser = argparse.ArgumentParser(description="Generate email and twitter announcements"
                                                  " from template")
     parser.add_argument("--version", dest="versions", type=str, required=True, action='append',
                         help="Versions of Ansible to announce")
     parser.add_argument("--name", type=str, required=True, help="Real name to use on emails")
     parser.add_argument("--email-out", type=str, default="-",
                         help="Filename to place the email announcement into")
     parser.add_argument("--twitter-out", type=str, default="-",
                         help="Filename to place the twitter announcement into")
 
     args = parser.parse_args(args)
 
     # Make it possible to sort versions in the jinja2 templates
     new_versions = []
     for version in args.versions:
         new_versions.append(VersionStr(version))
     args.versions = new_versions
 
     return args
 
 
 async def calculate_hash_from_tarball(session, version):
     tar_url = f'https://releases.ansible.com/ansible/ansible-{version}.tar.gz'
     tar_task = asyncio.create_task(session.get(tar_url))
     tar_response = await tar_task
 
     tar_hash = hashlib.sha256()
     while True:
         chunk = await tar_response.content.read(1024)
         if not chunk:
             break
         tar_hash.update(chunk)
 
     return tar_hash.hexdigest()
 
 
 async def parse_hash_from_file(session, version):
     filename = f'ansible-{version}.tar.gz'
     hash_url = f'https://releases.ansible.com/ansible/{filename}.sha'
     hash_task = asyncio.create_task(session.get(hash_url))
     hash_response = await hash_task
 
     hash_content = await hash_response.read()
     precreated_hash, precreated_filename = hash_content.split(None, 1)
     if filename != precreated_filename.strip().decode('utf-8'):
         raise ValueError(f'Hash file contains hash for a different file: {precreated_filename}')
 
     return precreated_hash.decode('utf-8')
 
 
 async def get_hash(session, version):
     calculated_hash = await calculate_hash_from_tarball(session, version)
     precreated_hash = await parse_hash_from_file(session, version)
 
     if calculated_hash != precreated_hash:
         raise ValueError(f'Hash in file ansible-{version}.tar.gz.sha {precreated_hash} does not'
                          f' match hash of tarball {calculated_hash}')
 
     return calculated_hash
 
 
 async def get_hashes(versions):
     hashes = {}
     requestors = {}
     async with aiohttp.ClientSession() as aio_session:
         for version in versions:
             requestors[version] = asyncio.create_task(get_hash(aio_session, version))
 
         for version, request in requestors.items():
             await request
             hashes[version] = request.result()
 
     return hashes
 
 
 def next_release_date(weeks=3):
     days_in_the_future = weeks * 7
     today = datetime.datetime.now()
     numeric_today = today.weekday()
 
     # We release on Thursdays
     if numeric_today == 3:
         # 3 is Thursday
         pass
     elif numeric_today == 4:
         # If this is Friday, we can adjust back to Thursday for the next release
         today -= datetime.timedelta(days=1)
     elif numeric_today < 3:
         # Otherwise, slide forward to Thursday
         today += datetime.timedelta(days=(3 - numeric_today))
     else:
         # slightly different formula if it's past Thursday this week.  We need to go forward to
         # Thursday of next week
         today += datetime.timedelta(days=(10 - numeric_today))
 
     next_release = today + datetime.timedelta(days=days_in_the_future)
     return next_release
 
 
 def generate_long_message(versions, name):
     hashes = asyncio.run(get_hashes(versions))
 
     version_template = JINJA_ENV.get_template('version_string')
     version_str = version_template.render(versions=versions).strip()
 
     next_release = next_release_date()
 
     template = JINJA_ENV.get_template('long')
     message = template.render(versions=versions, version_str=version_str,
                               name=name, hashes=hashes, next_release=next_release)
     return message
 
 
 def generate_short_message(versions):
     version_template = JINJA_ENV.get_template('version_string')
     version_str = version_template.render(versions=versions).strip()
 
     template = JINJA_ENV.get_template('short')
     message = template.render(versions=versions, version_str=version_str)
     message = ' '.join(message.split()) + '\n'
     return message
 
 
 def write_message(filename, message):
     if filename != '-':
         with open(filename, 'w') as out_file:
             out_file.write(message)
     else:
         sys.stdout.write('\n\n')
         sys.stdout.write(message)
 
 
 def main():
     args = parse_args(sys.argv[1:])
 
     twitter_message = generate_short_message(args.versions)
     email_message = generate_long_message(args.versions, args.name)
 
     write_message(args.twitter_out, twitter_message)
     write_message(args.email_out, email_message)
 
 
 if __name__ == '__main__':
     main()