#!/usr/bin/python3 """ Put into Public Domain, by Nicolas Sebrecht. Make a new release. """ #TODO: announce: cc list on announce includes all testers #TODO: announce: remove empty sections #TODO: websitedoc up #TODO: website branch not including all changes! from os import system, path, rename from datetime import datetime from subprocess import check_call import shlex import time from email import utils from helpers import ( MAILING_LIST, CACHEDIR, EDITOR, Git, OfflineimapInfo, Testers, User, run, goTo ) __VERSION__ = "0.2" SPHINXBUILD = 'sphinx-build' DOCSDIR = 'docs' CHANGELOG_MAGIC = '{:toc}' WEBSITE_LATEST = "website/_data/latest.yml" CHANGELOG_EXCERPT = "{}/changelog.excerpt.md".format(CACHEDIR) CHANGELOG_EXCERPT_OLD = "{}.old".format(CHANGELOG_EXCERPT) CHANGELOG = "Changelog.md" ANNOUNCE_FILE = "{}/announce.txt".format(CACHEDIR) WEBSITE_LATEST_SKEL = """# DO NOT EDIT MANUALLY: it is generated by the release script. stable: v{stable} """ CHANGELOG_SKEL = """ ### OfflineIMAP v{version} ({date}) #### Notes This release was tested by: {testersList} #### Authors {authorsList} #### Features #### Fixes #### Changes {commitsList} """ END_MESSAGE = """ Release is ready! Make your checks and push the changes for both offlineimap and the website. Announce template stands in '{announce}'. Command samples to do manually: - git push master next {new_version} - python2 setup.py sdist && twine upload dist/* && rm -rf dist MANIFEST - cd website - git checkout master - git merge {website_branch} - git push master - cd .. - git send-email {announce} ...and write a Twitter message. Have fun! ,-) """ class State(object): def __init__(self): self.master = None self.next = None self.website = None self.tag = None def setTag(self, tag): self.tag = tag def save(self): self.master = Git.getRef('master') self.next = Git.getRef('next') def saveWebsite(self): Git.chdirToRepositoryTopLevel() goTo('website') self.website = Git.getRef('master') goTo('..') def restore(self): Git.chdirToRepositoryTopLevel() try: Git.checkout('-f') except: pass # Git.checkout('master') # Git.resetKeep(self.master) # Git.checkout('next') # Git.resetKeep(self.next) if self.tag is not None: Git.rmTag(self.tag) if self.website is not None: if goTo('website'): Git.checkout(self.website) goTo('..') class Changelog(object): def __init__(self): self.shouldUsePrevious = False def edit(self): return system("{} {}".format(EDITOR, CHANGELOG_EXCERPT)) def update(self): # Insert excerpt to CHANGELOG. system("sed -i -e '/{}/ r {}' '{}'".format( CHANGELOG_MAGIC, CHANGELOG_EXCERPT, CHANGELOG ) ) # Remove trailing whitespaces. system("sed -i -r -e 's, +$,,' '{}'".format(CHANGELOG)) def savePrevious(self): rename(CHANGELOG_EXCERPT, CHANGELOG_EXCERPT_OLD) def isPrevious(self): if path.isfile(CHANGELOG_EXCERPT_OLD): return True return False def showPrevious(self): output = run(shlex.split("cat '{}'".format(CHANGELOG_EXCERPT_OLD))) for line in output.splitlines(): print(line.decode('utf-8')) # Weird to have to decode bytes here. def usePrevious(self): rename(CHANGELOG_EXCERPT_OLD, CHANGELOG_EXCERPT) self.shouldUsePrevious = True def usingPrevious(self): return self.shouldUsePrevious def writeExcerpt(self, version, date, testersList, authorsList, commitsList): with open(CHANGELOG_EXCERPT, 'w+') as fd: fd.write(CHANGELOG_SKEL.format( version=version, date=date, testersList=testersList, authorsList=authorsList, commitsList=commitsList, )) def getSectionsContent(self): dict_Content = {} with open(CHANGELOG_EXCERPT, 'r') as fd: currentSection = None for line in fd: line = line.rstrip() if line == "#### Notes": currentSection = 'Notes' dict_Content['Notes'] = "" continue # Don't keep this title. elif line == "#### Authors": currentSection = 'Authors' dict_Content['Authors'] = "" continue # Don't keep this title. elif line == "#### Features": currentSection = 'Features' dict_Content['Features'] = "" continue # Don't keep this title. elif line == "#### Fixes": currentSection = 'Fixes' dict_Content['Fixes'] = "" continue # Don't keep this title. elif line == "#### Changes": currentSection = 'Changes' dict_Content['Changes'] = "" continue # Don't keep this title. elif line == "-- ": break # Stop extraction. if currentSection is not None: dict_Content[currentSection] += "{}\n".format(line) #TODO: cleanup empty sections. return dict_Content class Announce(object): def __init__(self, version): self.fd = open(ANNOUNCE_FILE, 'w') self.version = version def setHeaders(self, messageId, date): self.fd.write("Message-Id: {}\n".format(messageId)) self.fd.write("Date: {}\n".format(date)) self.fd.write("From: Nicolas Sebrecht \n") self.fd.write("To: {}\n".format(MAILING_LIST)) self.fd.write( "Subject: [ANNOUNCE] OfflineIMAP v{} released\n".format(self.version)) self.fd.write("\n") self.fd.write(""" OfflineIMAP v{version} is out. Downloads: http://github.com/OfflineIMAP/offlineimap/archive/v{version}.tar.gz http://github.com/OfflineIMAP/offlineimap/archive/v{version}.zip Pip: wget "https://raw.githubusercontent.com/OfflineIMAP/offlineimap/v{version}/requirements.txt" -O requirements.txt pip install -r ./requirements.txt --user git+https://github.com/OfflineIMAP/offlineimap.git@v{version} """.format(version=self.version) ) def setContent(self, dict_Content): self.fd.write("\n") for section in ['Notes', 'Authors', 'Features', 'Fixes', 'Changes']: if section in dict_Content: if section != "Notes": self.fd.write("# {}\n".format(section)) self.fd.write(dict_Content[section]) self.fd.write("\n") # Signature. self.fd.write("-- \n") self.fd.write("Nicolas Sebrecht\n") def close(self): self.fd.close() class Website(object): def updateUploads(self): req = ("add new archive to uploads/ on the website? " "(warning: checksums will change if it already exists)") if User.yesNo(req, defaultToYes=True) is False: return False if check_call(shlex.split("./docs/build-uploads.sh")) != 0: return exit(5) return True def updateAPI(self): req = "update API of the website? (requires {})".format(SPHINXBUILD) if User.yesNo(req, defaultToYes=True) is False: return False try: if check_call(shlex.split("{} --version".format(SPHINXBUILD))) != 0: raise RuntimeError("{} not found".format(SPHINXBUILD)) except: print(""" Oops! you don't have {} installed?" Cannot update the webite documentation..." You should install it and manually run:" $ cd {}" $ make websitedoc" Then, commit and push changes of the website.""".format(SPHINXBUILD, DOCSDIR)) User.pause() return False Git.chdirToRepositoryTopLevel() if not goTo('website'): User.pause() return False if not Git.isClean: print("There is WIP in the website repository: stashing") Git.stash('WIP during offlineimap API import') goTo('..') return True def buildLatest(self, version): Git.chdirToRepositoryTopLevel() with open(WEBSITE_LATEST, 'w') as fd: fd.write(WEBSITE_LATEST_SKEL.format(stable=version)) def exportDocs(self): if not goTo(DOCSDIR): User.pause() return if check_call(shlex.split("make websitedoc")) != 0: print("error while calling 'make websitedoc'") exit(3) def createImportBranch(self, version): branchName = "import-v{}".format(version) Git.chdirToRepositoryTopLevel() if not goTo("website"): User.pause() return Git.checkout(branchName, create=True) Git.add('.') Git.commit("update for offlineimap v{}".format(version)) User.pause( "website: branch '{}' is ready for a merge in master!".format( branchName ) ) goTo('..') return branchName class Release(object): def __init__(self): self.state = State() self.offlineimapInfo = OfflineimapInfo() self.testers = Testers() self.changelog = Changelog() self.websiteBranch = "NO_BRANCH_NAME_ERROR" def getVersion(self): return self.offlineimapInfo.getVersion() def prepare(self): if not Git.isClean(): print("The git repository is not clean; aborting") exit(1) Git.makeCacheDir() Git.checkout('next') def requestVersion(self, currentVersion): User.request("going to make a new release after {}".format(currentVersion)) def updateVersion(self): self.offlineimapInfo.editVersion() def checkVersions(self, current, new): if new == current: print("version was not changed; stopping.") exit(1) def updateChangelog(self): if self.changelog.isPrevious(): self.changelog.showPrevious() if User.yesNo("A previous Changelog excerpt was found. Use it?"): self.changelog.usePrevious() if not self.changelog.usingPrevious(): date = datetime.now().strftime('%Y-%m-%d') testersList = "" testers = self.testers.getListOk() authorsList = "" authors = Git.getAuthorsList(currentVersion) for tester in testers: testersList += "- {}\n".format(tester.getName()) for author in authors: authorsList += "- {} ({})\n".format( author.getName(), author.getCount() ) commitsList = Git.getCommitsList(currentVersion) date = datetime.now().strftime('%Y-%m-%d') self.changelog.writeExcerpt( newVersion, date, testersList, authorsList, commitsList ) self.changelog.edit() self.changelog.update() def writeAnnounce(self): announce = Announce(newVersion) messageId = utils.make_msgid('release.py', 'laposte.net') nowtuple = datetime.now().timetuple() nowtimestamp = time.mktime(nowtuple) date = utils.formatdate(nowtimestamp) announce.setHeaders(messageId, date) announce.setContent(self.changelog.getSectionsContent()) announce.close() def make(self): Git.add('offlineimap/version.py') Git.add('Changelog.md') commitMsg = "v{}\n".format(newVersion) for tester in self.testers.getListOk(): commitMsg = "{}\nTested-by: {} {}".format( commitMsg, tester.getName(), tester.getEmail() ) Git.commit(commitMsg) self.state.setTag(newVersion) Git.tag(newVersion) Git.checkout('master') Git.mergeFF('next') Git.checkout('next') def updateWebsite(self, newVersion): self.state.saveWebsite() website = Website() website.buildLatest(newVersion) res_upload = website.updateUploads() res_api = website.updateAPI() if res_api: res_export = website.exportDocs() if True in [res_upload, res_api, res_export]: self.websiteBranch = website.createImportBranch(newVersion) def getWebsiteBranch(self): return self.websiteBranch def after(self): for protectedRun in [self.testers.reset, self.changelog.savePrevious]: try: protectedRun() except Exception as e: print(e) def restore(self): self.state.restore() if __name__ == '__main__': release = Release() Git.chdirToRepositoryTopLevel() try: release.prepare() currentVersion = release.getVersion() release.requestVersion(currentVersion) release.updateVersion() newVersion = release.getVersion() release.checkVersions(currentVersion, newVersion) release.updateChangelog() release.writeAnnounce() User.pause() release.make() release.updateWebsite(newVersion) release.after() websiteBranch = release.getWebsiteBranch() print(END_MESSAGE.format( announce=ANNOUNCE_FILE, new_version=newVersion, website_branch=websiteBranch) ) except Exception as e: release.restore() raise