release.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. #!/usr/bin/python3
  2. """
  3. Put into Public Domain, by Nicolas Sebrecht.
  4. Make a new release.
  5. """
  6. # TODO: announce: cc list on announce includes all testers
  7. # TODO: announce: remove empty sections
  8. # TODO: websitedoc up
  9. # TODO: website branch not including all changes!
  10. from os import system, path, rename
  11. from datetime import datetime
  12. from subprocess import check_call
  13. import shlex
  14. import time
  15. from email import utils
  16. from helpers import (
  17. MAILING_LIST, CACHEDIR, EDITOR, Git, OfflineimapInfo, Testers, User, run, goTo
  18. )
  19. __VERSION__ = "0.2"
  20. SPHINXBUILD = 'sphinx-build'
  21. DOCSDIR = 'docs'
  22. CHANGELOG_MAGIC = '{:toc}'
  23. WEBSITE_LATEST = "website/_data/latest.yml"
  24. CHANGELOG_EXCERPT = "{}/changelog.excerpt.md".format(CACHEDIR)
  25. CHANGELOG_EXCERPT_OLD = "{}.old".format(CHANGELOG_EXCERPT)
  26. CHANGELOG = "Changelog.md"
  27. ANNOUNCE_FILE = "{}/announce.txt".format(CACHEDIR)
  28. WEBSITE_LATEST_SKEL = """# DO NOT EDIT MANUALLY: it is generated by the release script.
  29. stable: v{stable}
  30. """
  31. CHANGELOG_SKEL = """
  32. ### OfflineIMAP v{version} ({date})
  33. #### Notes
  34. This release was tested by:
  35. {testersList}
  36. #### Authors
  37. {authorsList}
  38. #### Features
  39. #### Fixes
  40. #### Changes
  41. {commitsList}
  42. """
  43. END_MESSAGE = """
  44. Release is ready!
  45. Make your checks and push the changes for both offlineimap and the website.
  46. Announce template stands in '{announce}'.
  47. Command samples to do manually:
  48. - git push <remote> master next {new_version}
  49. - python setup.py sdist && twine upload dist/* && rm -rf dist MANIFEST
  50. - cd website
  51. - git checkout master
  52. - git merge {website_branch}
  53. - git push <remote> master
  54. - cd ..
  55. - git send-email {announce}
  56. ...and write a Twitter message.
  57. Have fun! ,-)
  58. """
  59. class State():
  60. def __init__(self):
  61. self.master = None
  62. self.next = None
  63. self.website = None
  64. self.tag = None
  65. def setTag(self, tag):
  66. self.tag = tag
  67. def save(self):
  68. self.master = Git.getRef('master')
  69. self.next = Git.getRef('next')
  70. def saveWebsite(self):
  71. Git.chdirToRepositoryTopLevel()
  72. goTo('website')
  73. self.website = Git.getRef('master')
  74. goTo('..')
  75. def restore(self):
  76. Git.chdirToRepositoryTopLevel()
  77. try:
  78. Git.checkout('-f')
  79. except:
  80. pass
  81. # Git.checkout('master')
  82. # Git.resetKeep(self.master)
  83. # Git.checkout('next')
  84. # Git.resetKeep(self.next)
  85. if self.tag is not None:
  86. Git.rmTag(self.tag)
  87. if self.website is not None:
  88. if goTo('website'):
  89. Git.checkout(self.website)
  90. goTo('..')
  91. class Changelog():
  92. def __init__(self):
  93. self.shouldUsePrevious = False
  94. def edit(self):
  95. return system("{} {}".format(EDITOR, CHANGELOG_EXCERPT))
  96. def update(self):
  97. # Insert excerpt to CHANGELOG.
  98. system("sed -i -e '/{}/ r {}' '{}'".format(
  99. CHANGELOG_MAGIC, CHANGELOG_EXCERPT, CHANGELOG
  100. )
  101. )
  102. # Remove trailing whitespaces.
  103. system("sed -i -r -e 's, +$,,' '{}'".format(CHANGELOG))
  104. def savePrevious(self):
  105. rename(CHANGELOG_EXCERPT, CHANGELOG_EXCERPT_OLD)
  106. def isPrevious(self):
  107. if path.isfile(CHANGELOG_EXCERPT_OLD):
  108. return True
  109. return False
  110. def showPrevious(self):
  111. output = run(shlex.split("cat '{}'".format(CHANGELOG_EXCERPT_OLD)))
  112. for line in output.splitlines():
  113. print((line.decode('utf-8'))) # Weird to have to decode bytes here.
  114. def usePrevious(self):
  115. rename(CHANGELOG_EXCERPT_OLD, CHANGELOG_EXCERPT)
  116. self.shouldUsePrevious = True
  117. def usingPrevious(self):
  118. return self.shouldUsePrevious
  119. def writeExcerpt(self, version, date,
  120. testersList, authorsList, commitsList):
  121. with open(CHANGELOG_EXCERPT, 'w+') as fd:
  122. fd.write(CHANGELOG_SKEL.format(
  123. version=version,
  124. date=date,
  125. testersList=testersList,
  126. authorsList=authorsList,
  127. commitsList=commitsList,
  128. ))
  129. def getSectionsContent(self):
  130. dict_Content = {}
  131. with open(CHANGELOG_EXCERPT, 'r') as fd:
  132. currentSection = None
  133. for line in fd:
  134. line = line.rstrip()
  135. if line == "#### Notes":
  136. currentSection = 'Notes'
  137. dict_Content['Notes'] = ""
  138. continue # Don't keep this title.
  139. elif line == "#### Authors":
  140. currentSection = 'Authors'
  141. dict_Content['Authors'] = ""
  142. continue # Don't keep this title.
  143. elif line == "#### Features":
  144. currentSection = 'Features'
  145. dict_Content['Features'] = ""
  146. continue # Don't keep this title.
  147. elif line == "#### Fixes":
  148. currentSection = 'Fixes'
  149. dict_Content['Fixes'] = ""
  150. continue # Don't keep this title.
  151. elif line == "#### Changes":
  152. currentSection = 'Changes'
  153. dict_Content['Changes'] = ""
  154. continue # Don't keep this title.
  155. elif line == "-- ":
  156. break # Stop extraction.
  157. if currentSection is not None:
  158. dict_Content[currentSection] += "{}\n".format(line)
  159. # TODO: cleanup empty sections.
  160. return dict_Content
  161. class Announce():
  162. def __init__(self, version):
  163. self.fd = open(ANNOUNCE_FILE, 'w')
  164. self.version = version
  165. def setHeaders(self, messageId, date):
  166. self.fd.write("Message-Id: {}\n".format(messageId))
  167. self.fd.write("Date: {}\n".format(date))
  168. self.fd.write("From: Nicolas Sebrecht <nicolas.s-dev@laposte.net>\n")
  169. self.fd.write("To: {}\n".format(MAILING_LIST))
  170. self.fd.write(
  171. "Subject: [ANNOUNCE] OfflineIMAP v{} released\n".format(self.version))
  172. self.fd.write("\n")
  173. self.fd.write("""
  174. OfflineIMAP v{version} is out.
  175. Downloads:
  176. http://github.com/OfflineIMAP/offlineimap/archive/v{version}.tar.gz
  177. http://github.com/OfflineIMAP/offlineimap/archive/v{version}.zip
  178. Pip:
  179. wget "https://raw.githubusercontent.com/OfflineIMAP/offlineimap/v{version}/requirements.txt" -O requirements.txt
  180. pip install -r ./requirements.txt --user git+https://github.com/OfflineIMAP/offlineimap.git@v{version}
  181. """.format(version=self.version)
  182. )
  183. def setContent(self, dict_Content):
  184. self.fd.write("\n")
  185. for section in ['Notes', 'Authors', 'Features', 'Fixes', 'Changes']:
  186. if section in dict_Content:
  187. if section != "Notes":
  188. self.fd.write("# {}\n".format(section))
  189. self.fd.write(dict_Content[section])
  190. self.fd.write("\n")
  191. # Signature.
  192. self.fd.write("-- \n")
  193. self.fd.write("Nicolas Sebrecht\n")
  194. def close(self):
  195. self.fd.close()
  196. class Website():
  197. def updateUploads(self):
  198. req = ("add new archive to uploads/ on the website? "
  199. "(warning: checksums will change if it already exists)")
  200. if User.yesNo(req, defaultToYes=True) is False:
  201. return False
  202. if check_call(shlex.split("./docs/build-uploads.sh")) != 0:
  203. return exit(5)
  204. return True
  205. def updateAPI(self):
  206. req = "update API of the website? (requires {})".format(SPHINXBUILD)
  207. if User.yesNo(req, defaultToYes=True) is False:
  208. return False
  209. try:
  210. if check_call(shlex.split("{} --version".format(SPHINXBUILD))) != 0:
  211. raise RuntimeError("{} not found".format(SPHINXBUILD))
  212. except:
  213. print(("""
  214. Oops! you don't have {} installed?"
  215. Cannot update the webite documentation..."
  216. You should install it and manually run:"
  217. $ cd {}"
  218. $ make websitedoc"
  219. Then, commit and push changes of the website.""".format(SPHINXBUILD, DOCSDIR)))
  220. User.pause()
  221. return False
  222. Git.chdirToRepositoryTopLevel()
  223. if not goTo('website'):
  224. User.pause()
  225. return False
  226. if not Git.isClean:
  227. print("There is WIP in the website repository: stashing")
  228. Git.stash('WIP during offlineimap API import')
  229. goTo('..')
  230. return True
  231. def buildLatest(self, version):
  232. Git.chdirToRepositoryTopLevel()
  233. with open(WEBSITE_LATEST, 'w') as fd:
  234. fd.write(WEBSITE_LATEST_SKEL.format(stable=version))
  235. def exportDocs(self):
  236. if not goTo(DOCSDIR):
  237. User.pause()
  238. return
  239. if check_call(shlex.split("make websitedoc")) != 0:
  240. print("error while calling 'make websitedoc'")
  241. exit(3)
  242. def createImportBranch(self, version):
  243. branchName = "import-v{}".format(version)
  244. Git.chdirToRepositoryTopLevel()
  245. if not goTo("website"):
  246. User.pause()
  247. return
  248. Git.checkout(branchName, create=True)
  249. Git.add('.')
  250. Git.commit("update for offlineimap v{}".format(version))
  251. User.pause(
  252. "website: branch '{}' is ready for a merge in master!".format(
  253. branchName
  254. )
  255. )
  256. goTo('..')
  257. return branchName
  258. class Release():
  259. def __init__(self):
  260. self.state = State()
  261. self.offlineimapInfo = OfflineimapInfo()
  262. self.testers = Testers()
  263. self.changelog = Changelog()
  264. self.websiteBranch = "NO_BRANCH_NAME_ERROR"
  265. def getVersion(self):
  266. return self.offlineimapInfo.getVersion()
  267. def prepare(self):
  268. if not Git.isClean():
  269. print("The git repository is not clean; aborting")
  270. exit(1)
  271. Git.makeCacheDir()
  272. Git.checkout('next')
  273. def requestVersion(self, currentVersion):
  274. User.request("going to make a new release after {}".format(currentVersion))
  275. def updateVersion(self):
  276. self.offlineimapInfo.editInit()
  277. def checkVersions(self, current, new):
  278. if new == current:
  279. print("version was not changed; stopping.")
  280. exit(1)
  281. def updateChangelog(self):
  282. if self.changelog.isPrevious():
  283. self.changelog.showPrevious()
  284. if User.yesNo("A previous Changelog excerpt was found. Use it?"):
  285. self.changelog.usePrevious()
  286. if not self.changelog.usingPrevious():
  287. date = datetime.now().strftime('%Y-%m-%d')
  288. testersList = ""
  289. testers = self.testers.getListOk()
  290. authorsList = ""
  291. authors = Git.getAuthorsList(currentVersion)
  292. for tester in testers:
  293. testersList += "- {}\n".format(tester.getName())
  294. for author in authors:
  295. authorsList += "- {} ({})\n".format(
  296. author.getName(), author.getCount()
  297. )
  298. commitsList = Git.getCommitsList(currentVersion)
  299. date = datetime.now().strftime('%Y-%m-%d')
  300. self.changelog.writeExcerpt(
  301. newVersion, date, testersList, authorsList, commitsList
  302. )
  303. self.changelog.edit()
  304. self.changelog.update()
  305. def writeAnnounce(self):
  306. announce = Announce(newVersion)
  307. messageId = utils.make_msgid('release.py', 'laposte.net')
  308. nowtuple = datetime.now().timetuple()
  309. nowtimestamp = time.mktime(nowtuple)
  310. date = utils.formatdate(nowtimestamp)
  311. announce.setHeaders(messageId, date)
  312. announce.setContent(self.changelog.getSectionsContent())
  313. announce.close()
  314. def make(self):
  315. Git.add('offlineimap/__init__.py')
  316. Git.add('Changelog.md')
  317. commitMsg = "v{}\n".format(newVersion)
  318. for tester in self.testers.getListOk():
  319. commitMsg = "{}\nTested-by: {} {}".format(
  320. commitMsg, tester.getName(), tester.getEmail()
  321. )
  322. Git.commit(commitMsg)
  323. self.state.setTag(newVersion)
  324. Git.tag(newVersion)
  325. Git.checkout('master')
  326. Git.mergeFF('next')
  327. Git.checkout('next')
  328. def updateWebsite(self, newVersion):
  329. self.state.saveWebsite()
  330. website = Website()
  331. website.buildLatest(newVersion)
  332. res_upload = website.updateUploads()
  333. res_api = website.updateAPI()
  334. if res_api:
  335. res_export = website.exportDocs()
  336. if True in [res_upload, res_api, res_export]:
  337. self.websiteBranch = website.createImportBranch(newVersion)
  338. def getWebsiteBranch(self):
  339. return self.websiteBranch
  340. def after(self):
  341. for protectedRun in [self.testers.reset, self.changelog.savePrevious]:
  342. try:
  343. protectedRun()
  344. except Exception as e:
  345. print(e)
  346. def restore(self):
  347. self.state.restore()
  348. if __name__ == '__main__':
  349. release = Release()
  350. Git.chdirToRepositoryTopLevel()
  351. try:
  352. release.prepare()
  353. currentVersion = release.getVersion()
  354. release.requestVersion(currentVersion)
  355. release.updateVersion()
  356. newVersion = release.getVersion()
  357. release.checkVersions(currentVersion, newVersion)
  358. release.updateChangelog()
  359. release.writeAnnounce()
  360. User.pause()
  361. release.make()
  362. release.updateWebsite(newVersion)
  363. release.after()
  364. websiteBranch = release.getWebsiteBranch()
  365. print((END_MESSAGE.format(
  366. announce=ANNOUNCE_FILE,
  367. new_version=newVersion,
  368. website_branch=websiteBranch)
  369. ))
  370. except Exception as e:
  371. release.restore()
  372. raise