From: Maria Matejka Date: Sat, 22 Nov 2025 22:23:28 +0000 (+0100) Subject: Tools: Release initialization script X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;ds=inline;p=thirdparty%2Fbird.git Tools: Release initialization script There is a bunch of things one needs to do in gitlab when releasing and this script simply checks whether there is everything needed and fixes what is missing, --- diff --git a/tools/release-issue.md.j2 b/tools/release-issue.md.j2 new file mode 100644 index 000000000..ddee48251 --- /dev/null +++ b/tools/release-issue.md.j2 @@ -0,0 +1,76 @@ +# BIRD {{ this.version }} release ({{ kind }}) + +*This is an autogenerated issue from `tools/release.py`. +Do not edit by hand unless you know what you are doing. +Just tick off what is done.* + +## End of development + +*Things seem to be done. Checking, gathering, last fixes.* + +- [ ] Issue last call in the team chat. +- [X] Next version milestone exists: [{{ next.milestone.name }}]({{ next.milestone.url }}) +{%- if kind == "minor" %} +- [X] First patch milestone: [{{ patch.milestone.name }}]({{ patch.milestone.url }}) +{%- endif %} +- [ ] [Current milestone ({{ this.milestone.name }})]({{ this.milestone.url }}) empty +- [ ] Walk once again over closed issues and check that they are indeed merged into [{{ this.branch.name }}]({{ this.branch.url }}) +{%- if kind == "patch" %} +- [ ] Walk once again over closed issues and check they are either applied on ({{ main.branch.name }}]({{ main.branch.url }}) or a linked issue is in [{{ main.milestone.name }}]({{ main.milestone.url }}) +{%- endif %} +- [ ] Pipeline is green (netlab, pkgs, install) *TODO: find out how to automate this* +- [ ] Documentation build works *TODO: find out how to automate this* + +## Code freeze + +*We are now confident that everything is working. This is the process +of doing all the non-coding things related to release.* + +- [ ] Text for announcements ready in [bird-notes](https://gitlab.nic.cz/labs/bird-notes/-/tree/master/release_notes/{{ this.version }}.md) *put a symlink there if multiple versions are being released* + - [ ] customer info + - [ ] mailing-list + - [ ] PR {% if kind == "patch" %}(not needed unless security){% endif %} + - [ ] socials +- [ ] NEWS and version update: `tools/release-commit patch` + - [~] Commit pushed, linked to this exact issue *%%AUTO:NEWS:TICK%%* +- [~] Official package archives on website: %%AUTO:NEWS:BLOCKED%% + - [ ] In bird-web: `make version-update PIPELINE=%%AUTO:NEWS:PIPELINE%%` + - [ ] In bird-web: Commit and run `make version-update` to check + - [ ] In bird-web: `git push origin` + - [ ] [Check SRC TGZ download at the staging website](https://bird-web-cz-nic-labs-bird-a63d8cfd75ea76921ab3b6970da0bbd08b1dc.pages.office.nic.cz/download/bird-{{ this.version }}.tar.gz) + - [ ] [Check DOC TGZ download at the staging website](https://bird-web-cz-nic-labs-bird-a63d8cfd75ea76921ab3b6970da0bbd08b1dc.pages.office.nic.cz/download/bird-doc-{{ this.version }}.tar.gz) + - [ ] In bird-web: `git push production` + - [ ] [Check SRC TGZ download at the production website](https://bird.nic.cz/download/bird-{{ this.version }}.tar.gz) + - [ ] [Check DOC TGZ download at the production website](https://bird.nic.cz/download/bird-doc-{{ this.version }}.tar.gz) +- [ ] Mail to downstream mailing-list sent +- [ ] Mail to customers sent +- [ ] Debian / Ubuntu packages done *TODO: find out how to automate this* +- [ ] CentOS packages done *TODO: find out how to automate this* + +## External release + +*Everything is now prepared to be shipped and we need to execute it.* + +- [~] TAG in BIRD repo added %%AUTO:TAG:TICK%% +- [ ] Pipeline still green after TAG +- [~] Update website %%AUTO:TAG:BLOCKED%% + - [ ] Add a new item in news + - [~] In bird-web: `make version-update PIPELINE=%%AUTO:TAG:PIPELINE%%` + - [ ] Commit and run `make version-update` again to check + - [ ] In bird-web: `./update-doc.py` + - [ ] In bird-web: `git add www/doc/*` + - [ ] In bird-web: `git commit -a` + - [ ] In bird-web: `git push origin` + - [ ] [Check documentation at the staging website](https://bird-web-cz-nic-labs-bird-a63d8cfd75ea76921ab3b6970da0bbd08b1dc.pages.office.nic.cz/doc/bird-{{ this.version }}.html) + - [ ] [Check news at the staging website](https://bird-web-cz-nic-labs-bird-a63d8cfd75ea76921ab3b6970da0bbd08b1dc.pages.office.nic.cz/) + - [ ] [Check download page at the staging website](https://bird-web-cz-nic-labs-bird-a63d8cfd75ea76921ab3b6970da0bbd08b1dc.pages.office.nic.cz/get-bird) + - [ ] In bird-web: `git push production` + - [ ] [Check documentation at the production website](https://bird.nic.cz/doc/bird-{{ this.version }}.html) + - [ ] [Check news at the production website](https://bird.nic.cz/) + - [ ] [Check download page at the production website](https://bird.nic.cz/get-bird) +- [ ] Announcement to bird-users mailing-list made +- [ ] PR department informed +- [ ] Boasted on socials (Mastodon, Linkedin) +- [ ] All relevant support tickets resolved / closed +- [ ] Post-release debrief done + diff --git a/tools/release.py b/tools/release.py new file mode 100755 index 000000000..62a404c74 --- /dev/null +++ b/tools/release.py @@ -0,0 +1,363 @@ +#!/usr/bin/python3 + +import jinja2 +import logging +import os +import pathlib +import requests +import subprocess +import sys + +logging.basicConfig(format='%(levelname)# 8s | %(message)s', level=logging.INFO) +logger = logging.getLogger(__name__) + +class CommandError(Exception): + def __str__(self): + return f"""Command {self.args[0].args} failed with code {self.args[0].returncode}. + {self.args[0].stdout.decode()} + {self.args[0].stderr.decode()}""" + +def cmd(*args): + result = subprocess.run(args, capture_output=True) + if result.returncode != 0: + raise CommandError(result) + + return result.stdout.decode().split("\n") + +class ReleaseException(Exception): + pass + +class Version: + def __init__(self, *args): + if len(args) == 3: + for a in args: + assert(isinstance(a, int)) + + self.major, self.minor, self.patch = tuple(args) + + else: + assert(len(args) == 1) + s = args[0].split('.') + if len(s) > 3: + raise ReleaseException(f"Weird version: {value} (too many dots)") + + try: + self.major = int(s[0]) + self.minor = int(s[1]) + except Exception as e: + raise ReleaseException(f"Weird version: {value}") from e + + try: + self.patch = int(s[2]) + except IndexError: + self.patch = 0 + except Exception as e: + raise ReleaseException(f"Weird version: {value}") from e + + if self.major == 2 and self.patch == 0: + self.branch = "master" + elif self.major == 3 and self.patch == 0: + self.branch = "thread-next" + else: + self.branch = f"stable-v{self.major}.{self.minor}" + + + def __eq__(self, other): + return self.major == other.major and self.minor == other.minor and self.patch == other.patch + + def __repr__(self): + return f"Version({str(self)})" + + def __str__(self): + if self.major == 2 and self.patch == 0: + return f"{self.major}.{self.minor}" + else: + return f"{self.major}.{self.minor}.{self.patch}" + + def next_patch(self): + return Version(self.major, self.minor, self.patch + 1) + + def next_minor(self): + return Version(self.major, self.minor + 1, 0) + + def next_major(self): + return Version(self.major + 1, 0, 0) + + def template_data(self): + return { + "version": str(self), + "branch": { + "name": self.branch, + "url": "https://gitlab.nic.cz/labs/bird/-/commits/" + self.branch, + }, + "milestone": Milestone(self).template_data(), + } + +class Milestone: + seen = {} + + def __new__(cls, version): + try: + return cls.seen[str(version)] + except KeyError: + logger.debug(f"Milestone v{version} not yet queried") + return super(Milestone, cls).__new__(cls) + + def __init__(self, version): + try: + assert(Milestone.seen[str(version)] == self) + return + except KeyError: + pass + + milestone = gitlab.get(f"milestones?title=v{version}") + if len(milestone) == 0: + milestone = gitlab.post(f"milestones", json={ + "title": f"v{version}", + "description": f"Collection of issues intended to be resolved in release {version}", + }) + logger.debug(f"Gitlab replied: {milestone}") + logger.info(f"Created milestone v{version}: {milestone['web_url']}") + self.id = milestone['iid'] + self.url = milestone['web_url'] + self.name = milestone['title'] + Milestone.seen[str(version)] = self + + elif len(milestone) == 1: + logger.info(f"Milestone v{version} already exists: {milestone[0]['web_url']}") + self.id = milestone[0]['iid'] + self.url = milestone[0]['web_url'] + self.name = milestone[0]['title'] + Milestone.seen[str(version)] = self + else: + raise ReleaseException(f"Too many milestones of name v{version}: {milestone}") + + def template_data(self): + return { + "name": self.name, + "url": self.url, + "id": self.id, + } + +# A singleton class accessing the current git state +class GitState: + def __init__(self): + # Normalize where we are + self.toplevel = pathlib.Path(sys.argv[0]).parent.parent.absolute() + os.chdir(self.toplevel) + + with open("VERSION", "r") as f: + self.version = Version(f.read().strip()) + + try: + gitbranch = [ x[3:] for x in cmd("git", "status", "-bs") if x.startswith("## ") ][0] + except Exception as e: + raise ReleaseException(f"Git status is broken, are you even inside a repo?") from e + + if "(no branch)" in gitbranch: + raise ReleaseException(f"Not on any branch, I refuse to release.") + + if "..." not in gitbranch and " " not in gitbranch: + raise ReleaseException(f"Detected branch {gitbranch} but not tracking any remote. I refuse to release.") + + try: + locbranch, remref = gitbranch.split("...") + remote, rembranch = remref.split("/") + remuri, _ = cmd("git", "remote", "get-url", remote) + except Exception as e: + raise ReleaseException(f"This does not look like a regular branch, git status says: {gitbranch}") from e + + if \ + "https" not in remuri and "git@" not in remuri \ + or "gitlab.nic.cz" not in remuri \ + or "labs/bird" not in remuri \ + or "office" in remuri: + raise ReleaseException(f"Current branch is {locbranch}, tracking {rembranch} at {remote} but the appropriate uri is kinda sus: {remuri}") + + + if locbranch != rembranch: + raise ReleaseException(f"Hey sis, your local branch {locbranch} tracks remote branch {rembranch} at {remote}. Go and fix that mess.") + + self.remote = remote + self.branch = locbranch + + def __str__(self): + return f"GitState(toplevel={self.toplevel},branch={self.branch},version={self.version})" + + def token(self): + try: + return self._token + except AttributeError: + try: + self._token, _ = cmd("git", "config", "gitlab.token") + return self._token + except CommandError as e: + raise ReleaseException(f"To use gitlab API, you need a token. Add one in \"Settings → Access Tokens\" and call \"git config set --local gitlab.token=\".") from e + + +class GitlabException(Exception): + def __str__(self): + return f"Gitlab request {self.args[0]} failed with {self.args[1].status_code}" + +# A singleton class providing raw Gitlab API +class Gitlab: + stem = "https://gitlab.nic.cz/api/v4/projects/labs%2Fbird/" + def get(self, uri): + response = requests.get(self.stem + uri, headers={"PRIVATE-TOKEN": git.token()}) + if not response.ok: + raise GitlabException(self.stem + uri, response) + + return response.json() + + def post(self, uri, **kwargs): + response = requests.post(self.stem + uri, headers={"PRIVATE-TOKEN": git.token()}, **kwargs) + if not response.ok: + raise GitlabException({ "uri": self.stem + uri, **kwargs }, response) + + return response.json() + +class Templater: + def __init__(self): + self.j2env = jinja2.Environment(loader=jinja2.FileSystemLoader(".")) + + def process(self, tpath, **data): + te = self.j2env.get_template(tpath) + return te.render(**data) + +# A singleton class doing the release +class Release: + def __new__(cls, *args, **kwargs): + if cls != Release: + return super(Release, cls).__new__(cls) + + version = None + if git.branch == "master": + cls = MinorRelease + assert(git.version.major == 2) + version = git.version.next_minor() + + elif git.branch == "thread-next": + cls = MinorRelease + assert(git.version.major == 3) + version = git.version.next_minor() + + elif git.branch == f"stable-v{git.version.major}.{git.version.minor}": + cls = PatchRelease + version = git.version.next_patch() + + elif git.branch.startswith("release-v"): + bv = git.branch[9:] + nmi = git.version.next_minor() + npa = git.version.next_patch() + + if Version(bv) == git.version: + version = git.version + cls = MinorRelease if git.version.patch == 0 else PatchRelease + elif Version(bv) == nmi: + version = nmi + cls = MinorRelease + elif Version(bv) == npa: + version = npa + cls = PatchRelease + else: + raise ReleaseException(f"Release branch {git.branch} incongruent with its VERSION {git.version}") + else: + raise ReleaseException(f"I have no idea what to release from branch {git.branch}") + + obj = cls.__new__(cls) + obj.version = version + return obj + + def __init__(self): + logger.info(f"Releasing {self.kind} version {self.version} from branch {git.branch}") + super().__init__() + + def issue(self): + issue = gitlab.get(f"issues?labels=release-checklist&state=opened&milestone=v{self.version}") + if len(issue) == 0: + logger.info(f"Release issue does not exist yet, creating") +# print({ + issue = gitlab.post(f"issues", json={ + "title": f"BIRD {self.version} release ({ self.kind })", + "labels": f"release-{self.kind},release-checklist", + "milestone_id": Milestone(self.version).id, + "description": templater.process( + "tools/release-issue.md.j2", + **(self.issue_template_data()), + kind=self.kind, + ) + }) + logger.info(f"Check the release issue #{issue['iid']} at {issue['web_url']}") + + elif len(issue) == 1: + logger.info(f"Release issue #{issue[0]['iid']} already exists: {issue[0]['web_url']}") + else: + raise ReleaseException(f"Too many release issues for version {version}: {[ i['web_url'] for i in issue].join(', ')}") + + + def create_branch(self): + name = f"release-v{self.version}" + logger.info(f"Creating branch {name}") + try: + cmd("git", "checkout", "-b", name) + except CommandError as e: + raise ReleaseException(f"Failed to create branch {name}") from e + + def run(self): + # Check commit history + try: + assert(cmd("tools/git-check-commits") == [""]) + except Exception as e: + raise ReleaseException("Commit structure unsuitable for release!") from e + +# Not creating release branch, maybe later +# if not git.branch.startswith("release-v"): +# self.create_branch() + + # Assure + self.milestones() + self.issue() + + +# Subclasses to define things where Minor and Patch release actually differ +class MinorRelease(Release): + kind = "minor" + + def milestones(self): + Milestone(self.version) # The version we are currently releasing + Milestone(self.version.next_minor()) # What didn't make it + Milestone(self.version.next_patch()) # Fixes of this version + + def issue_template_data(self): + return { + "this": self.version.template_data(), + "next": self.version.next_minor().template_data(), + "patch": self.version.next_patch().template_data(), + } + +class PatchRelease(Release): + kind = "patch" + + def milestones(self): + Milestone(self.version) # The version we are currently releasing + Milestone(self.version.next_minor()) # What actually isn't a bug worth patchfixing + Milestone(self.version.next_patch()) # Fixes of this version + + def issue_template_data(self): + return { + "this": self.version.template_data(), + "next": self.version.next_patch().template_data(), + "main": self.version.next_minor().template_data(), + } + +# Do the release preparation +try: + git = GitState() + gitlab = Gitlab() + templater = Templater() + release = Release() + release.run() +except ReleaseException as e: + logger.error(e, exc_info=True) +except Exception: + logger.exception("Fatal error", exc_info=True)