]> git.ipfire.org Git - thirdparty/kea.git/commitdiff
[#4497] Add changelog-lint CI job
authorAndrei Pavel <andrei@isc.org>
Thu, 7 May 2026 09:22:24 +0000 (12:22 +0300)
committerAndrei Pavel <andrei@isc.org>
Thu, 21 May 2026 14:29:15 +0000 (17:29 +0300)
.gitlab-ci.yml
tools/changelog_lint.py [new file with mode: 0755]

index 5adee8ee51ae04260ed9256136420c1130fa0d88..5fd65126ae19cb0c5fd1c49fd8ab27ca91050947 100644 (file)
@@ -48,6 +48,12 @@ are-database-scripts-in-sync:
   script:
     - ./src/share/database/scripts/utils/are-scripts-in-sync.py
 
+changelog-lint:
+  stage: test
+  <<: *rules_for_test_stage
+  script:
+    - tools/changelog_lint.py
+
 check-for-json-errors-in-doc:
   stage: test
   <<: *rules_for_test_stage
diff --git a/tools/changelog_lint.py b/tools/changelog_lint.py
new file mode 100755 (executable)
index 0000000..f039688
--- /dev/null
@@ -0,0 +1,274 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2019-2026 Internet Systems Consortium, Inc. ("ISC")
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+"""Lints Kea changelog files.
+
+Usages:
+./changelog-lint.py
+./changelog-lint.py kea
+./changelog-lint.py kea-premium
+./changelog-lint.py 1.2.3 kea/ChangeLog ./a.html False
+./changelog-lint.py 1.2.3 kea-premium/ChangeLog ./a.html False
+./changelog-lint.py 1.2.3 kea/changelog_unreleased/1234-bugfix ./a.html True
+./changelog-lint.py 1.2.3 kea-premium/changelog_unreleased/1234-bugfix ./a.html True
+"""
+
+# pylint: disable = fixme
+# pylint: disable = global-statement
+# pylint: disable = too-many-nested-blocks
+
+# TODO: add check spelling with enchant?
+
+import datetime
+import glob
+import sys
+import re
+
+errors = 0
+output = None
+
+
+def trace(msg):
+    """Print a message and append it to a file if the file is configured.
+
+    :param msg: message to print
+    :type msg: str
+    """
+    print(msg)
+    if output:
+        with open(output, 'a', encoding='utf-8') as f:
+            f.write(msg + '<br>')
+
+
+def error(lineno, line, msg):
+    """Increment an error count and print details about the error.
+
+    :param lineno: line number from the changelog file where the error occurs indexed from 0
+    :type lineno: int
+    :param line: the line from the changelog file where the error occurs
+    :type line: str
+    :param msg: the error message
+    :type msg: str
+    """
+    global errors
+    errors += 1
+    trace(f'{lineno + 1}: "{line}"')
+    trace(f'  {msg}')
+
+
+def main(kea_version, changelog_path, output_path=None, is_entry_file=False):
+    """Entry point.
+
+    :param kea_version:
+    :type kea_version:
+    :param changelog_path:
+    :type changelog_path:
+    :param output_path:
+    :type output_path:
+    :param is_entry_file:
+    :type is_entry_file:
+    :return: whether there were no errors
+    :rtype: bool
+    """
+    global output
+    output = output_path
+
+    title = f'ChangeLog Lint: {changelog_path}'
+    trace(title)
+    trace('=' * len(title))
+
+    with open(changelog_path, encoding='utf-8') as f:
+        lines = f.readlines()
+
+    prev_entry_id = None
+    text = ""
+    entries_num = 0
+    releases = []
+    allowed_categories = ['bug', 'build', 'doc', 'func', 'sec', 'perf', 'test']
+
+    rel_date_checked = False
+    for idx, line in enumerate(lines):
+        line = line[:-1]
+
+        # check trailing whitespace
+        if line != line.rstrip():
+            error(idx, line, 'Trailing whitespace.')
+
+        if len(line) == 0:
+            # skip empty line
+            continue
+
+        # Check line length but only in the first 100 lines
+        # (the rest can be skipped as they were already checked).
+        # This check is only for 1.9.x and newer Kea.
+        if not kea_version.startswith(("1.6", "1.8")):
+            line_t = line.replace('\t', ' ' * 8)
+            length = len(line_t)
+            if idx < 100 and length > 73 and 'released on' not in line:
+                error(
+                    idx, line_t, f'Line is too long ({length}), should be < 73 characters (tab is counted as 8 spaces).'
+                )
+
+        # first line on entry of ChangeLog
+        if not is_entry_file and line[0].isdigit():
+            entries_num += 1
+            text = ""
+            m = re.search(r"^(\d+)\.\s+\[(.+)\]\*?\s+(.+)", line)
+            if not m:
+                error(idx, line, 'Malformed first entry line. Expected: "<num>.   [<categories>]   <authors>".')
+                break
+            entry_id, category, authors = m.groups()
+
+            # check authors
+            for char in authors:
+                if not char.isalnum() and char not in [' ', ',', '-', '.', '_', '(', ')', '[', ']']:
+                    error(idx, line, 'Authors contain other characters than alphanumeric, dash, dot, underline, '
+                          f'round brackets, or square brackets: "{authors}".')
+
+            # check categories
+            categories = category.split(',')
+            categories = [cat.strip() for cat in categories]
+            for cat in categories:
+                if cat not in allowed_categories:
+                    error(idx, line, f'Unknown category "{cat}".')
+
+            # check entry id
+            try:
+                entry_id = int(entry_id)
+            except (TypeError, ValueError):
+                error(idx, line, f'Entry ID "{entry_id}" is not an integer.')
+                break
+            if prev_entry_id is not None and entry_id not in [1148, 1491]:
+                if entry_id != prev_entry_id - 1:
+                    error(idx, line, f'Entry ID is not consecutive ("{entry_id}" while previous is "{prev_entry_id}").')
+            prev_entry_id = entry_id
+
+        # first line of the ChangeLog entry file
+        elif is_entry_file and line[0] == '[':
+            entries_num += 1
+            text = ""
+
+            m = re.search(r"^\[(.+)\]\*?\t\t(.+)", line)
+            if not m:
+                error(idx, line, 'Malformed first entry line. Expected: "[<categories>]   <authors>".')
+                break
+            category, authors = m.groups()
+
+            # check categories
+            categories = category.split(',')
+            categories = [cat.strip() for cat in categories]
+            for cat in categories:
+                if cat not in allowed_categories:
+                    error(idx, line, f'Unknown category "{cat}".')
+
+        # line of entry text
+        elif line[0] == '\t':
+            text += line + '\n'
+
+        # release line
+        elif line.startswith('Kea'):
+            if is_entry_file:
+                error(idx, line, 'Release line should not be in entry file.')
+                break
+
+            releases.append(line)
+            m = re.match(r'Kea (\S+) (\S*) *released on (.+)', line)
+            if not m:
+                m = re.match(r'Kea-premium hooks package (\S+) (\S*) *released on (.+)', line)
+            if not m:
+                error(idx, line, 'Release line is malformed.')
+            else:
+                version, release_type, rel_date = m.groups()
+                if version in ['0.9.1beta', '0.9', '0.9beta1', '0.9beta1']:
+                    # ignore old versions
+                    continue
+                version_parts = version.split('-')
+                ver_nums = version_parts[0].split('.')
+                if len(ver_nums) != 3:
+                    error(idx, line, f'Version number "{version_parts[0]}" is not divided by 2 dots.')
+                for n in ver_nums:
+                    if not n.isdigit():
+                        error(idx, line, f'Version number "{n}" is not a digit.')
+                ver_type = ''
+                if len(version_parts) == 2:
+                    ver_type = version_parts[1]
+                    if not ver_type.startswith('beta'):
+                        error(idx, line, f'Version type "{ver_type}" is malformed. It should start with "beta".')
+
+                ver0 = int(ver_nums[0])
+                ver1 = int(ver_nums[1])
+                if (ver0 == 1 and ver1 >= 6) or ver0 > 1:
+                    if not release_type:
+                        error(idx, line, 'Missing release type, it should be either (development) or (stable)')
+                    elif release_type not in ['(development)', '(stable)']:
+                        error(
+                            idx,
+                            line,
+                            f'Release type should be either (development) or (stable) but it is {release_type}.',
+                        )
+                    else:
+                        if (ver1 % 2 == 1 or 'beta' in ver_type) and release_type != '(development)':
+                            error(
+                                idx,
+                                line,
+                                'Release type for odd minor version or beta should be (development), but is '
+                                f'{release_type}.',
+                            )
+                        if ver1 % 2 == 0 and 'beta' not in ver_type and release_type != '(stable)':
+                            error(
+                                idx,
+                                line,
+                                'Release type for even minor version and not beta should be (stable), but is '
+                                f'{release_type}.',
+                            )
+                # Check only the latest release date.
+                if not rel_date_checked:
+                    try:
+                        datetime.datetime.strptime(rel_date, "%B %d, %Y")
+                    except ValueError:
+                        error(idx, line, f'Release date does not conform to format "%B %d, %Y": "{rel_date}"')
+                    rel_date_checked = True
+
+        # rubbish to ignore
+        elif line.startswith('bind10'):
+            # ignore
+            pass
+
+        # last entry, stop processing
+        elif prev_entry_id == 1:
+            break
+
+        else:
+            error(idx, line, 'Entry text should be indented by tabulator sign.')
+
+    trace('Summary:')
+    if not is_entry_file:
+        trace(f'last release: {releases[0]}')
+    trace(f'entries: {entries_num}')
+    trace(f'errors: {errors}')
+    trace('')
+    return errors == 0
+
+
+if __name__ == '__main__':
+    argc = len(sys.argv)
+    success = True
+    if argc <= 2:
+        directory = '.' if argc == 1 else sys.argv[1]
+        success &= main('x.y.z', f'{directory}/ChangeLog', None, is_entry_file=False)
+        for i in glob.glob(f'{directory}/changelog_unreleased/*'):
+            success &= main('x.y.z', i, None, is_entry_file=True)
+    else:
+        success &= main(
+            sys.argv[1],
+            sys.argv[2],
+            sys.argv[3] if argc <= 4 else None,
+            (sys.argv[4].lower() in ['true', 'yes']) if argc <= 5 else False,
+        )
+    if not success:
+        sys.exit(1)