blob: 0076a41184e27540001a8e7664db632aedcf439c [file]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Validates changelog YAML files.
Usage:
validate-changelog-yaml.py <file1> [<file2> ...] Validate one or more files
validate-changelog-yaml.py <folder> Validate all .yml/.yaml files in folder
Checks:
- File is valid YAML
- All top-level keys are valid (title, type, issues, links, important_notes, modules, authors)
- Deprecated keys (merge_requests, configurations) are not used
- Contains required 'title' field (non-empty string)
- Contains required 'type' field (one of: added, changed, fixed, deprecated, removed, dependency_update, security, other)
- Contains required 'authors' field with at least one author
- Each author has a 'name' field (non-empty string)
- Contains either 'links' or 'issues' field (or both)
- If 'issues' is present, it must be a list of integers not exceeding 17000
- If 'links' is present, each entry must be a mapping with 'name' and 'url' fields (not a plain string)
- Comment block is removed
"""
import sys
from pathlib import Path
import yaml
def validate_changelog_yaml(file_path):
"""Validate a changelog YAML file. Returns True if valid."""
valid_types = ['added', 'changed', 'fixed', 'deprecated', 'removed', 'dependency_update', 'security', 'other']
valid_keys = ['title', 'type', 'issues', 'links', 'important_notes', 'modules', 'authors']
deprecated_keys = ['merge_requests', 'configurations']
not_allowed_text = ['DELETE ALL COMMENTS UP HERE', 'Most such changes are too small']
try:
with open(file_path, 'r', encoding='utf-8') as f:
raw_content = f.read()
data = yaml.safe_load(raw_content)
# Check if file contains a mapping (dictionary)
if not isinstance(data, dict):
print(f"::error file={file_path}::File must contain YAML mapping (key-value pairs)")
return False
# Check for invalid top-level keys
for key in data.keys():
if key not in valid_keys and key not in deprecated_keys:
print(f"::error file={file_path}::Invalid top-level key '{key}'. Valid keys are: {', '.join(valid_keys)}")
return False
# Check for deprecated keys
for deprecated_key in deprecated_keys:
if deprecated_key in data:
print(f"::error file={file_path}::Our project does not use the '{deprecated_key}' yaml key, please remove")
return False
# Validate 'title' field
if 'title' not in data or not data['title']:
print(f"::error file={file_path}::Missing or empty 'title' field")
return False
if not isinstance(data['title'], str) or not data['title'].strip():
print(f"::error file={file_path}::Field 'title' must be a non-empty string")
return False
# Validate 'type' field
if 'type' not in data or not data['type']:
print(f"::error file={file_path}::Missing or empty 'type' field")
return False
if data['type'] not in valid_types:
print(f"::error file={file_path}::Invalid 'type': '{data['type']}'. Must be one of: {', '.join(valid_types)}")
return False
# Validate 'authors' field
if 'authors' not in data or not data['authors']:
print(f"::error file={file_path}::Missing or empty 'authors' field")
return False
if not isinstance(data['authors'], list) or len(data['authors']) == 0:
print(f"::error file={file_path}::Field 'authors' must be a non-empty list")
return False
for i, author in enumerate(data['authors']):
if not isinstance(author, dict):
print(f"::error file={file_path}::Author {i} must be a mapping (key-value pairs)")
return False
if 'name' not in author or not author['name']:
print(f"::error file={file_path}::Author {i} missing or empty 'name' field")
return False
if not isinstance(author['name'], str) or not author['name'].strip():
print(f"::error file={file_path}::Author {i} 'name' must be a non-empty string")
return False
# Validate that either 'links' or 'issues' exists (or both)
if 'links' not in data and 'issues' not in data:
print(f"::error file={file_path}::Must contain either 'links' or 'issues' key (or both)")
return False
# Validate 'issues' field if present
if 'issues' in data:
if not isinstance(data['issues'], list):
print(f"::error file={file_path}::Field 'issues' must be a list of integers")
return False
for i, issue in enumerate(data['issues']):
if not isinstance(issue, int):
print(f"::error file={file_path}::Field 'issues' entry {i} must be an integer")
return False
if issue > 17000:
print(f"::error file={file_path}::Field 'issues' value {issue} points to a non-existing github PR. Did you intend to reference a JIRA issue, please use 'links'.")
return False
# Validate 'links' field if present
if 'links' in data:
if not isinstance(data['links'], list):
print(f"::error file={file_path}::Field 'links' must be a list")
return False
for i, link in enumerate(data['links']):
if not isinstance(link, dict):
print(f"::error file={file_path}::Link {i} must be a mapping with 'name' and 'url' fields, not a plain string")
return False
if 'name' not in link or not link['name']:
print(f"::error file={file_path}::Link {i} missing or empty 'name' field")
return False
if not isinstance(link['name'], str) or not link['name'].strip():
print(f"::error file={file_path}::Link {i} 'name' must be a non-empty string")
return False
if 'url' not in link or not link['url']:
print(f"::error file={file_path}::Link {i} missing or empty 'url' field")
return False
if not isinstance(link['url'], str) or not link['url'].strip():
print(f"::error file={file_path}::Link {i} 'url' must be a non-empty string")
return False
# Validate that comments are removed
for not_allowed in not_allowed_text:
if not_allowed in raw_content:
print(f"::error file={file_path}::File still contains commented template text. Please remove the comment block at the top of the file.")
return False
# All validations passed
print(f"✓ {file_path} is valid")
print(f" Title: {data['title']}")
print(f" Type: {data['type']}")
print(f" Authors: {', '.join(a['name'] for a in data['authors'])}")
return True
except yaml.YAMLError as e:
print(f"::error file={file_path}::Invalid YAML: {e}")
return False
except Exception as e:
print(f"::error file={file_path}::Error validating file: {e}")
return False
def collect_files(args):
"""Expand a mix of file paths and folder paths into a list of YAML files."""
files = []
for arg in args:
p = Path(arg)
if p.is_dir():
found = sorted(p.glob("*.yml")) + sorted(p.glob("*.yaml"))
if not found:
print(f"Warning: no .yml/.yaml files found in {p}", file=sys.stderr)
files.extend(found)
elif p.is_file():
files.append(p)
else:
print(f"::error::Not a file or directory: {arg}")
sys.exit(1)
return files
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: validate-changelog-yaml.py <file1> [<file2> ...] | <folder>")
sys.exit(1)
files = collect_files(sys.argv[1:])
if not files:
sys.exit(0)
failed = False
for f in files:
if not validate_changelog_yaml(f):
failed = True
sys.exit(1 if failed else 0)