[elbe-devel] [PATCH 2/2] contrib: add release preparation script
Thomas Weißschuh
thomas.weissschuh at linutronix.de
Tue Mar 4 13:42:02 CET 2025
The release process has a few steps that previously were all performed
manually. This is a lot of work and error-prone.
Add a script which takes care of everything.
This has already been used for the v15.5 release.
Signed-off-by: Thomas Weißschuh <thomas.weissschuh at linutronix.de>
---
contrib/prepare-release.py | 343 +++++++++++++++++++++++++++++++++++++++++++++
pyproject.toml | 1 +
2 files changed, 344 insertions(+)
diff --git a/contrib/prepare-release.py b/contrib/prepare-release.py
new file mode 100644
index 0000000000000000000000000000000000000000..eee595f4b335e30be29579cc492e069b7e81740c
--- /dev/null
+++ b/contrib/prepare-release.py
@@ -0,0 +1,343 @@
+#!/usr/bin/env python3
+# ELBE - Debian Based Embedded Rootfilesystem Builder
+# SPDX-License-Identifier: GPL-3.0-or-later
+# SPDX-FileCopyrightText: 2025 Linutronix GmbH
+
+"""
+Perform release steps in the ELBE source repository.
+"""
+
+import datetime
+import enum
+import functools
+import pathlib
+import subprocess
+import textwrap
+
+from debian.changelog import Changelog
+from debian.deb822 import Deb822, PkgRelation, _PkgRelationMixin
+from debian.debian_support import Release
+
+
+def debian_version(base, debian_release=None):
+ if debian_release is None:
+ return str(base)
+ return f'{base}~bpo{debian_release.version}'
+
+
+def get_current_version():
+ cl = Changelog()
+ with open('debian/changelog') as f:
+ cl.parse_changelog(f)
+
+ return cl.get_version()
+
+
+def update_changelog(version, debian_release, release_notes):
+ cl = Changelog()
+ with open('debian/changelog') as f:
+ cl.parse_changelog(f)
+
+ cl.new_block(
+ package=cl.package,
+ version=debian_version(version),
+ distributions=str(debian_release),
+ urgency=cl.urgency,
+ changes=[
+ '',
+ ' * Team upload',
+ f' * Release notes in {release_notes}',
+ '',
+ ],
+ author=cl.author,
+ date=cl.date,
+ )
+
+ with open('debian/changelog', 'w') as f:
+ cl.write_to_open_file(f)
+
+
+def update_changelog_backport(debian_release):
+ cl = Changelog()
+ with open('debian/changelog') as f:
+ cl.parse_changelog(f)
+
+ current = cl.get_version()
+
+ cl.new_block(
+ package=cl.package,
+ version=debian_version(current, debian_release),
+ distributions=str(debian_release),
+ urgency=cl.urgency,
+ changes=[
+ '',
+ ' * Team upload',
+ f' * Rebuild for {release}',
+ '',
+ ],
+ author=cl.author,
+ date=cl.date,
+ )
+
+ with open('debian/changelog', 'w') as f:
+ cl.write_to_open_file(f)
+
+
+ at functools.total_ordering
+class StripRestrictionResult(enum.Enum):
+ NONE = enum.auto()
+ KEEP = enum.auto()
+ DISCARD = enum.auto()
+
+ def __gt__(self, other):
+ return self.value > other.value
+
+
+class StripDistRestrictions:
+ def __init__(self, dist):
+ self.dist = dist
+
+ def __call__(self, relations):
+ return self._visit_relations(relations)
+
+ # The hierarchy:
+ # relations = list[outer_relation]
+ # outer_relation = list[inner_relation]
+ # inner_relation = dict[restrictions=restriction]
+ # restrictions = list[outer_restriction]
+ # outer_restriction = list[inner_restriction]
+ # inner_restriction = Restriction
+
+ @staticmethod
+ def __remove_falsy(es):
+ return [e for e in es if e]
+
+ def _visit_relations(self, relations):
+ return self.__remove_falsy([
+ self._visit_outer_relation(outer_relation)
+ for outer_relation
+ in relations
+ ])
+
+ def _visit_outer_relation(self, outer_relation):
+ return self.__remove_falsy([
+ self._visit_inner_relation(inner_relation)
+ for inner_relation
+ in outer_relation
+ ])
+
+ def _visit_inner_relation(self, inner_relation):
+ restrictions = inner_relation.get('restrictions')
+ if not restrictions:
+ return inner_relation
+
+ inner_relation = inner_relation.copy()
+ result, restrictions = self._visit_restrictions(restrictions)
+
+ if result == StripRestrictionResult.DISCARD:
+ return None
+
+ inner_relation['restrictions'] = restrictions or None
+ return inner_relation
+
+ def _visit_restrictions(self, restrictions):
+ outers = [
+ self._visit_outer_restriction(outer_restriction)
+ for outer_restriction
+ in restrictions
+ ]
+
+ result = StripRestrictionResult.NONE
+ ret = []
+
+ for r, i in outers:
+ if r == StripRestrictionResult.KEEP:
+ continue
+ result = max(result, r)
+ ret.append(i)
+
+ return result, ret or None
+
+ def _visit_outer_restriction(self, outer_restriction):
+ inners = [
+ self._visit_inner_restriction(inner_restriction)
+ for inner_restriction
+ in outer_restriction
+ ]
+
+ result = StripRestrictionResult.NONE
+ ret = []
+
+ for r, i in inners:
+ result = max(result, r)
+ ret.append(i)
+
+ return result, ret or None
+
+ def _visit_inner_restriction(self, inner_restriction):
+ if not inner_restriction.profile.startswith('dist.'):
+ return StripRestrictionResult.NONE, inner_restriction
+ elif (inner_restriction.profile == f'dist.{self.dist}') == inner_restriction.enabled:
+ return StripRestrictionResult.KEEP, inner_restriction
+ else:
+ return StripRestrictionResult.DISCARD, inner_restriction
+
+
+def strip_release_restrictions(relations, release):
+ return StripDistRestrictions(release)(relations)
+
+
+def test_strip_release_restriction_include():
+ import textwrap
+
+ test_data = textwrap.dedent("""
+ dep1, dep2 <dep2profile>, dep3 <!dist.bullseye>, dep4 <dist.bullseye>
+ """)
+
+ relations = PkgRelation.parse_relations(test_data)
+ relations = strip_release_restrictions(relations, Release.releases['bullseye'])
+ assert PkgRelation.str(relations) == 'dep1, dep2 <dep2profile>, dep4'
+
+
+def test_strip_release_restriction_exclude():
+ import textwrap
+
+ test_data = textwrap.dedent("""
+ dep1, dep2 <dep2profile>, dep3 <!dist.bullseye>, dep4 <dist.bullseye>
+ """)
+
+ relations = PkgRelation.parse_relations(test_data)
+ relations = strip_release_restrictions(relations, Release.releases['buster'])
+ assert PkgRelation.str(relations) == 'dep1, dep2 <dep2profile>, dep3'
+
+
+class DebianControl(Deb822, _PkgRelationMixin):
+ _relationship_fields = [
+ 'build-depends',
+ ]
+
+ def __init__(self, *args, **kwargs):
+ Deb822.__init__(self, *args, **kwargs)
+ _PkgRelationMixin.__init__(self, *args, **kwargs)
+
+
+def update_control(release):
+ with open('debian/control') as f:
+ paragraphs = list(DebianControl.iter_paragraphs(f))
+
+ for paragraph in paragraphs:
+ bds = paragraph.relations['build-depends']
+ if not bds:
+ continue
+
+ paragraph['build-depends'] = PkgRelation.str(
+ strip_release_restrictions(bds, release),
+ ).replace(', ', ',\n ')
+
+ with open('debian/control', 'w') as f:
+ for i, paragraph in enumerate(paragraphs):
+ if i:
+ f.write('\n')
+ paragraph.dump(f, text_mode=True)
+
+
+def update_version_py_remove_dev0(current_version, new_version):
+ with open('elbepack/version.py') as f:
+ source = f.read()
+
+ source = source.replace(f"'{current_version}'", f"'{new_version}'")
+
+ new_source = source
+
+ source = source.replace(textwrap.dedent("""
+ if is_devel:
+ elbe_version += '.dev0'
+ """), '\n')
+
+ with open('elbepack/version.py', 'w') as f:
+ f.write(source)
+
+ return new_source
+
+
+def update_version_py_add_dev0(source):
+ with open('elbepack/version.py', 'w') as f:
+ f.write(source)
+
+
+def create_release_notes(version):
+ date = datetime.date.today().isoformat()
+ subprocess.check_call(['towncrier', 'build', '--version', version, '--date', date])
+ release_notes = pathlib.Path(f'docs/news/{date}-v{version}.rst')
+ if not release_notes.is_file():
+ raise ValueError(f'release notes where not created at {release_notes}')
+ return release_notes
+
+
+def create_commit(message):
+ subprocess.check_call(['git', 'commit', '-a', '-s', '-m', message])
+
+
+def create_release_tags(version, debian_release=None):
+ # public, backwards compatible tag
+ v = f'v{version}' if debian_release is None else f'v{version}_{debian_release}'
+ subprocess.check_call(['git', 'tag', '--sign', '-m', f'release: {v}', f'{v}'])
+
+ # structured tag for Linutronix automation
+ v = debian_version(version, debian_release).replace('~', '_')
+ subprocess.check_call(['git', 'tag', '--sign', '-m', f'release: {v}', f'releases/rebase/{v}'])
+
+
+def create_release_branch(version, debian_release=None):
+ if debian_release is None:
+ branch = f'releases/v{version}'
+ else:
+ branch = f'releases/v{version}_{debian_release}'
+
+ subprocess.check_call(['git', 'checkout', '-b', branch])
+
+
+if __name__ == '__main__':
+ import argparse
+
+ parser = argparse.ArgumentParser(description=__doc__)
+
+ def _get_release(s):
+ try:
+ return Release.releases[s]
+ except KeyError as e:
+ raise argparse.ArgumentTypeError(f'Invalid release: {s}') from e
+
+ parser.add_argument('version', help='New version to release')
+ parser.add_argument('release', type=_get_release,
+ help='Primary targeted Debian release')
+ parser.add_argument('backports', nargs='+', type=_get_release,
+ help='Additional Debian releases to create backports for')
+ args = parser.parse_args()
+
+ # Make sure the repository is clean
+ subprocess.check_call(['git', 'diff', '--exit-code'])
+ subprocess.check_call(['git', 'checkout', 'for-master'])
+
+ current_version = get_current_version()
+ version = args.version
+ release = args.release
+
+ new_version_py = update_version_py_remove_dev0(current_version, version)
+ release_notes = create_release_notes(version)
+ update_changelog(version, release, release_notes)
+ create_commit(f'release: v{version}')
+ create_release_tags(version)
+ create_release_branch(version)
+
+ for backport in args.backports:
+ subprocess.check_call(['git', 'checkout', f'v{version}'])
+ create_release_branch(version, backport)
+ update_changelog_backport(backport)
+ update_control(backport)
+ create_commit(f'release: v{version} {backport} backport')
+ create_release_tags(version, backport)
+
+ subprocess.check_call(['git', 'checkout', 'for-master'])
+ update_version_py_add_dev0(new_version_py)
+ create_commit('release: back to development')
diff --git a/pyproject.toml b/pyproject.toml
index b0d4475cc5207e431ce4149ecb02e134154c343f..064b8060f7978150304829169a0f55317b750a3b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,7 @@ python_files = [
'test_*.py',
'*_test.py',
'contrib/check-deb-py-versions.py',
+ 'contrib/prepare-release.py',
]
filterwarnings = 'error'
--
2.48.1
More information about the elbe-devel
mailing list