From: Andrei Pavel Date: Thu, 7 May 2026 09:22:24 +0000 (+0300) Subject: [#4497] Add changelog-lint CI job X-Git-Tag: Kea-3.1.9~42 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=c35be388986b3e4d683ce4a8dcb90848bb0aef2d;p=thirdparty%2Fkea.git [#4497] Add changelog-lint CI job --- diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5adee8ee51..5fd65126ae 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -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 index 0000000000..f03968898a --- /dev/null +++ b/tools/changelog_lint.py @@ -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 + '
') + + +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: ". [] ".') + 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: "[] ".') + 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)