python-testtools/scripts/_lp_release.py

233 lines
7.5 KiB
Python

#!/usr/bin/python
"""Release testtools on Launchpad.
Steps:
1. Make sure all "Fix committed" bugs are assigned to 'next'
2. Rename 'next' to the new version
3. Release the milestone
4. Upload the tarball
5. Create a new 'next' milestone
6. Mark all "Fix committed" bugs in the milestone as "Fix released"
Assumes that NEWS is in the parent directory, that the release sections are
underlined with '~' and the subsections are underlined with '-'.
Assumes that this file is in the 'scripts' directory a testtools tree that has
already had a tarball built and uploaded with 'python setup.py sdist upload
--sign'.
"""
from datetime import datetime, timedelta, tzinfo
import logging
import os
import sys
from launchpadlib.launchpad import Launchpad
from launchpadlib import uris
APP_NAME = 'testtools-lp-release'
CACHE_DIR = os.path.expanduser('~/.launchpadlib/cache')
SERVICE_ROOT = uris.LPNET_SERVICE_ROOT
FIX_COMMITTED = "Fix Committed"
FIX_RELEASED = "Fix Released"
# Launchpad file type for a tarball upload.
CODE_RELEASE_TARBALL = 'Code Release Tarball'
PROJECT_NAME = 'testtools'
NEXT_MILESTONE_NAME = 'next'
class _UTC(tzinfo):
"""UTC"""
def utcoffset(self, dt):
return timedelta(0)
def tzname(self, dt):
return "UTC"
def dst(self, dt):
return timedelta(0)
UTC = _UTC()
def configure_logging():
level = logging.INFO
log = logging.getLogger(APP_NAME)
log.setLevel(level)
handler = logging.StreamHandler()
handler.setLevel(level)
formatter = logging.Formatter("%(levelname)s: %(message)s")
handler.setFormatter(formatter)
log.addHandler(handler)
return log
LOG = configure_logging()
def get_path(relpath):
"""Get the absolute path for something relative to this file."""
return os.path.abspath(
os.path.join(
os.path.dirname(os.path.dirname(__file__)), relpath))
def assign_fix_committed_to_next(testtools, next_milestone):
"""Find all 'Fix Committed' and make sure they are in 'next'."""
fixed_bugs = list(testtools.searchTasks(status=FIX_COMMITTED))
for task in fixed_bugs:
LOG.debug(f"{task.title}")
if task.milestone != next_milestone:
task.milestone = next_milestone
LOG.info(f"Re-assigning {task.title}")
task.lp_save()
def rename_milestone(next_milestone, new_name):
"""Rename 'next_milestone' to 'new_name'."""
LOG.info(f"Renaming {next_milestone.name} to {new_name}")
next_milestone.name = new_name
next_milestone.lp_save()
def get_release_notes_and_changelog(news_path):
release_notes = []
changelog = []
state = None
last_line = None
def is_heading_marker(line, marker_char):
return line and line == marker_char * len(line)
LOG.debug(f"Loading NEWS from {news_path}")
with open(news_path) as news:
for line in news:
line = line.strip()
if state is None:
if (is_heading_marker(line, '~') and
not last_line.startswith('NEXT')):
milestone_name = last_line
state = 'release-notes'
else:
last_line = line
elif state == 'title':
# The line after the title is a heading marker line, so we
# ignore it and change state. That which follows are the
# release notes.
state = 'release-notes'
elif state == 'release-notes':
if is_heading_marker(line, '-'):
state = 'changelog'
# Last line in the release notes is actually the first
# line of the changelog.
changelog = [release_notes.pop(), line]
else:
release_notes.append(line)
elif state == 'changelog':
if is_heading_marker(line, '~'):
# Last line in changelog is actually the first line of the
# next section.
changelog.pop()
break
else:
changelog.append(line)
else:
raise ValueError("Couldn't parse NEWS")
release_notes = '\n'.join(release_notes).strip() + '\n'
changelog = '\n'.join(changelog).strip() + '\n'
return milestone_name, release_notes, changelog
def release_milestone(milestone, release_notes, changelog):
date_released = datetime.now(tz=UTC)
LOG.info(
f"Releasing milestone: {milestone.name}, date {date_released}")
release = milestone.createProductRelease(
date_released=date_released,
changelog=changelog,
release_notes=release_notes,
)
milestone.is_active = False
milestone.lp_save()
return release
def create_milestone(series, name):
"""Create a new milestone in the same series as 'release_milestone'."""
LOG.info(f"Creating milestone {name} in series {series.name}")
return series.newMilestone(name=name)
def close_fixed_bugs(milestone):
tasks = list(milestone.searchTasks())
for task in tasks:
LOG.debug(f"Found {task.title}")
if task.status == FIX_COMMITTED:
LOG.info(f"Closing {task.title}")
task.status = FIX_RELEASED
else:
LOG.warning(
f"Bug not fixed, removing from milestone: {task.title}")
task.milestone = None
task.lp_save()
def upload_tarball(release, tarball_path):
with open(tarball_path) as tarball:
tarball_content = tarball.read()
sig_path = tarball_path + '.asc'
with open(sig_path) as sig:
sig_content = sig.read()
tarball_name = os.path.basename(tarball_path)
LOG.info(f"Uploading tarball: {tarball_path}")
release.add_file(
file_type=CODE_RELEASE_TARBALL,
file_content=tarball_content, filename=tarball_name,
signature_content=sig_content,
signature_filename=sig_path,
content_type="application/x-gzip; charset=binary")
def release_project(launchpad, project_name, next_milestone_name):
testtools = launchpad.projects[project_name]
next_milestone = testtools.getMilestone(name=next_milestone_name)
release_name, release_notes, changelog = get_release_notes_and_changelog(
get_path('NEWS'))
LOG.info(f"Releasing {project_name} {release_name}")
# Since reversing these operations is hard, and inspecting errors from
# Launchpad is also difficult, do some looking before leaping.
errors = []
tarball_path = get_path(f'dist/{project_name}-{release_name}.tar.gz')
if not os.path.isfile(tarball_path):
errors.append(f"{tarball_path} does not exist")
if not os.path.isfile(tarball_path + '.asc'):
errors.append("{} does not exist".format(tarball_path + '.asc'))
if testtools.getMilestone(name=release_name):
errors.append(f"Milestone {release_name} exists on {project_name}")
if errors:
for error in errors:
LOG.error(error)
return 1
assign_fix_committed_to_next(testtools, next_milestone)
rename_milestone(next_milestone, release_name)
release = release_milestone(next_milestone, release_notes, changelog)
upload_tarball(release, tarball_path)
create_milestone(next_milestone.series_target, next_milestone_name)
close_fixed_bugs(next_milestone)
return 0
def main(args):
launchpad = Launchpad.login_with(
APP_NAME, SERVICE_ROOT, CACHE_DIR, credentials_file='.lp_creds')
return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME)
if __name__ == '__main__':
sys.exit(main(sys.argv))