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

This commit includes the milestone fix.

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..742248c
--- /dev/null
@@ -0,0 +1,366 @@
+#!/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.gitlab_id = milestone['id']
+            self.local_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.gitlab_id = milestone[0]['id']
+            self.local_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,
+                "local_id": self.local_id,
+                "gitlab_id": self.gitlab_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).gitlab_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)