← Back to team overview

testtools-dev team mailing list archive

[Merge] lp:~jml/testtools/automate-lp-release into lp:testtools

 

Jonathan Lange has proposed merging lp:~jml/testtools/automate-lp-release into lp:testtools.

Requested reviews:
  testtools developers (testtools-dev)

For more details, see:
https://code.launchpad.net/~jml/testtools/automate-lp-release/+merge/66640

Automate the release process. Not sure what needs explaining. Have tested fairly rigorously against staging.
-- 
https://code.launchpad.net/~jml/testtools/automate-lp-release/+merge/66640
Your team testtools developers is requested to review the proposed merge of lp:~jml/testtools/automate-lp-release into lp:testtools.
=== modified file 'Makefile'
--- Makefile	2011-06-30 16:57:12 +0000
+++ Makefile	2011-07-01 17:18:32 +0000
@@ -1,6 +1,6 @@
 # See README for copyright and licensing details.
 
-PYTHON=python3
+PYTHON=python
 SOURCES=$(shell find testtools -name "*.py")
 
 check:
@@ -22,6 +22,7 @@
 
 release:
 	./setup.py sdist upload --sign
+	$(PYTHON) _lp_release.py
 
 snapshot: prerelease
 	./setup.py sdist

=== modified file 'NEWS'
--- NEWS	2011-06-30 16:57:12 +0000
+++ NEWS	2011-07-01 17:18:32 +0000
@@ -10,6 +10,9 @@
 * All public matchers are now in ``testtools.matchers.__all__``.
   (Jonathan Lange, #784859)
 
+* Automated the Launchpad part of the release process.
+  (Jonathan Lange, #623486)
+
 * New convenience assertions, ``assertIsNone`` and ``assertIsNotNone``.
   (Christian Kampka)
 

=== added file '_lp_release.py'
--- _lp_release.py	1970-01-01 00:00:00 +0000
+++ _lp_release.py	2011-07-01 17:18:32 +0000
@@ -0,0 +1,228 @@
+#!/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 same directory, that the release sections are
+underlined with '~' and the subsections are underlined with '-'.
+
+Assumes that this file is in the top-level of 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 = u"Fix Committed"
+FIX_RELEASED = u"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(__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("%s" % (task.title,))
+        if task.milestone != next_milestone:
+            task.milestone = next_milestone
+            LOG.info("Re-assigning %s" % (task.title,))
+            task.lp_save()
+
+
+def rename_milestone(next_milestone, new_name):
+    """Rename 'next_milestone' to 'new_name'."""
+    LOG.info("Renaming %s to %s" % (next_milestone.name, 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("Loading NEWS from %s" % (news_path,))
+    with open(news_path, 'r') as news:
+        for line in news:
+            line = line.strip()
+            if state is None:
+                if is_heading_marker(line, '~'):
+                    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(
+        "Releasing milestone: %s, date %s" % (milestone.name, 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("Creating milestone %s in series %s" % (name, series.name))
+    return series.newMilestone(name=name)
+
+
+def close_fixed_bugs(milestone):
+    tasks = list(milestone.searchTasks())
+    for task in tasks:
+        LOG.debug("Found %s" % (task.title,))
+        if task.status == FIX_COMMITTED:
+            LOG.info("Closing %s" % (task.title,))
+            task.status = FIX_RELEASED
+        else:
+            LOG.warning(
+                "Bug not fixed, removing from milestone: %s" % (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("Uploading tarball: %s" % (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("Releasing %s %s" % (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('dist/%s-%s.tar.gz' % (project_name, release_name,))
+    if not os.path.isfile(tarball_path):
+        errors.append("%s does not exist" % (tarball_path,))
+    if not os.path.isfile(tarball_path + '.asc'):
+        errors.append("%s does not exist" % (tarball_path + '.asc',))
+    if testtools.getMilestone(name=release_name):
+        errors.append("Milestone %s exists on %s" % (release_name, 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)
+    return release_project(launchpad, PROJECT_NAME, NEXT_MILESTONE_NAME)
+
+
+if __name__ == '__main__':
+    sys.exit(main(sys.argv))

=== modified file 'doc/hacking.rst'
--- doc/hacking.rst	2011-06-12 00:37:20 +0000
+++ doc/hacking.rst	2011-07-01 17:18:32 +0000
@@ -129,26 +129,16 @@
    which should not be replaced).
 #. Commit the changes.
 #. Tag the release, bzr tag testtools-X.Y.Z
-#. Create a source distribution and upload to pypi ('make release').
-#. Make sure all "Fix committed" bugs are in the 'next' milestone on
-   Launchpad
-#. Rename the 'next' milestone on Launchpad to 'X.Y.Z'
-#. Create a release on the newly-renamed 'X.Y.Z' milestone
-
-   * Make the milestone inactive (this is the default)
-   * Set the release date to the current day
-
-#. Upload the tarball and asc file to Launchpad
+#. Run 'make release', this:
+   #. Creates a source distribution and uploads to PyPI
+   #. Ensures all Fix Committed bugs are in the release milestone
+   #. Makes a release on Launchpad and uploads the tarball
+   #. Marks all the Fix Committed bugs as Fix Released
+   #. Creates a new milestone
 #. Merge the release branch testtools-X.Y.Z into trunk. Before the commit,
    add a NEXT heading to the top of NEWS and bump the version in __init__.py.
    Push trunk to Launchpad
 #. If a new series has been created (e.g. 0.10.0), make the series on Launchpad.
-#. Make a new milestone for the *next release*.
-
-   #. During release we rename NEXT to $version.
-   #. We call new milestones NEXT.
-
-#. Set all bugs that were "Fix Committed" to "Fix Released"
 
 .. _PEP 8: http://www.python.org/dev/peps/pep-0008/
 .. _unittest: http://docs.python.org/library/unittest.html


Follow ups