From b3a0c23025bd07f041faae4491552e98af44aeb0 Mon Sep 17 00:00:00 2001 From: Marc Alexander Date: Mon, 28 Sep 2026 10:46:28 +0200 Subject: [PATCH] Add GitHub Actions workflow to validate phpBB version files Checks that phpbb/versions.json is valid JSON with the expected channel/branch structure, and that the phpbb/*.txt files follow the current line-based formats. --- .github/scripts/validate_phpbb.py | 138 ++++++++++++++++++++++++++++++ .github/workflows/validate.yml | 28 ++++++ 2 files changed, 166 insertions(+) create mode 100644 .github/scripts/validate_phpbb.py create mode 100644 .github/workflows/validate.yml diff --git a/.github/scripts/validate_phpbb.py b/.github/scripts/validate_phpbb.py new file mode 100644 index 0000000..ef5a819 --- /dev/null +++ b/.github/scripts/validate_phpbb.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Validate phpbb/versions.json and the phpbb/*.txt version check files.""" + +import json +import re +import sys +from pathlib import Path + +PHPBB_DIR = Path(__file__).resolve().parents[2] / 'phpbb' + +VERSION_RE = re.compile(r'^\d+\.\d+\.\d+(-(a|b|rc|pl)\d+)?$', re.IGNORECASE) +BRANCH_RE = re.compile(r'^\d+\.\d+$') +URL_RE = re.compile(r'^https?://\S+$') + +REQUIRED_KEYS = {'current', 'announcement', 'eol', 'security'} +OPTIONAL_KEYS = {'urgent'} + +errors = [] + + +def error(file, message): + errors.append(f'{file}: {message}') + + +def is_version_or_false(value): + return value is False or (isinstance(value, str) and VERSION_RE.match(value)) + + +def validate_versions_json(path): + try: + data = json.loads(path.read_text(encoding='utf-8')) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e: + error(path.name, f'invalid JSON: {e}') + return + + if not isinstance(data, dict) or not data: + error(path.name, 'top level must be a non-empty object') + return + + for channel, branches in data.items(): + if not isinstance(branches, dict) or not branches: + error(path.name, f'"{channel}" must be a non-empty object') + continue + + for branch, info in branches.items(): + where = f'{channel}.{branch}' + if not BRANCH_RE.match(branch): + error(path.name, f'{where}: invalid branch name') + if not isinstance(info, dict): + error(path.name, f'{where}: must be an object') + continue + + missing = REQUIRED_KEYS - info.keys() + unknown = info.keys() - REQUIRED_KEYS - OPTIONAL_KEYS + if missing: + error(path.name, f'{where}: missing keys {sorted(missing)}') + if unknown: + error(path.name, f'{where}: unknown keys {sorted(unknown)}') + + current = info.get('current') + if 'current' in info: + if not isinstance(current, str) or not VERSION_RE.match(current): + error(path.name, f'{where}.current: invalid version {current!r}') + elif not current.startswith(branch + '.'): + error(path.name, f'{where}.current: {current} does not belong to branch {branch}') + + announcement = info.get('announcement') + if 'announcement' in info and not (isinstance(announcement, str) and URL_RE.match(announcement)): + error(path.name, f'{where}.announcement: invalid URL {announcement!r}') + + eol = info.get('eol') + if 'eol' in info and eol is not None and not isinstance(eol, str): + error(path.name, f'{where}.eol: must be null or a string') + + for key in ('security', 'urgent'): + if key in info and not is_version_or_false(info[key]): + error(path.name, f'{where}.{key}: must be false or a version, got {info[key]!r}') + + +def read_lines(path): + try: + content = path.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError) as e: + error(path.name, f'unreadable: {e}') + return None + if '\r' in content: + error(path.name, 'contains CR characters (use LF line endings)') + return content.rstrip('\n').split('\n') + + +def validate_20x_txt(path): + """Three lines with the major, minor and revision numbers of the 2.0.x release.""" + lines = read_lines(path) + if lines is None: + return + if len(lines) != 3: + error(path.name, f'expected 3 lines, got {len(lines)}') + for i, line in enumerate(lines, 1): + if not line.isdigit(): + error(path.name, f'line {i}: expected a number, got {line!r}') + + +def validate_pairs_txt(path): + """Alternating version / announcement URL lines.""" + lines = read_lines(path) + if lines is None: + return + if not lines or len(lines) % 2: + error(path.name, f'expected version/URL line pairs, got {len(lines)} lines') + for i, line in enumerate(lines, 1): + if i % 2: + if not VERSION_RE.match(line): + error(path.name, f'line {i}: invalid version {line!r}') + elif not URL_RE.match(line): + error(path.name, f'line {i}: invalid URL {line!r}') + + +def main(): + validate_versions_json(PHPBB_DIR / 'versions.json') + + txt_files = sorted(PHPBB_DIR.glob('*.txt')) + for path in txt_files: + if path.name == '20x.txt': + validate_20x_txt(path) + else: + validate_pairs_txt(path) + + if errors: + for message in errors: + print(f'::error::{message}') + return 1 + + print(f'versions.json and {len(txt_files)} txt file(s) are valid') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..9750742 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,28 @@ +name: Validate + +on: + push: + paths: + - 'phpbb/**' + - '.github/**' + pull_request: + paths: + - 'phpbb/**' + - '.github/**' + +permissions: + contents: read + +jobs: + phpbb: + name: Validate phpBB version files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Validate phpbb/versions.json and phpbb/*.txt + run: python .github/scripts/validate_phpbb.py