]> git.ipfire.org Git - thirdparty/bird.git/commitdiff
Tools: Release initialization script master
authorMaria Matejka <mq@ucw.cz>
Sat, 22 Nov 2025 22:23:28 +0000 (23:23 +0100)
committerMaria Matejka <mq@ucw.cz>
Sat, 22 Nov 2025 22:23:28 +0000 (23:23 +0100)
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,

tools/release-issue.md.j2 [new file with mode: 0644]
tools/release.py [new file with mode: 0755]

diff --git a/tools/release-issue.md.j2 b/tools/release-issue.md.j2
new file mode 100644 (file)
index 0000000..ddee482
--- /dev/null
@@ -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 (executable)
index 0000000..62a404c
--- /dev/null
@@ -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=<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)