Fix line continuation in generate_schedule() that caused Sphinx to fail with "Bullet list ends without a blank line; unexpected unindent" error. Add tests to validate RST formatting of generated documentation. Co-developed-by: Claude <noreply@anthropic.com> Signed-off-by: Simon Glass <sjg@chromium.org>
506 lines
17 KiB
Python
Executable File
506 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: GPL-2.0+
|
|
"""
|
|
Release version calculation for U-Boot concept releases.
|
|
|
|
This script calculates the appropriate version number and type for U-Boot
|
|
releases based on the current date and release schedule.
|
|
|
|
Release Schedule:
|
|
- Final releases: First Monday of even-numbered months
|
|
(Feb, Apr, Jun, Aug, Oct, Dec)
|
|
- Release candidates: Every second Monday before final release
|
|
- rc1: 6 weeks before final release (first RC)
|
|
- rc2: 4 weeks before final release
|
|
- rc3: 2 weeks before final release (last RC)
|
|
"""
|
|
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Tuple, NamedTuple
|
|
|
|
|
|
class ReleaseInfo(NamedTuple):
|
|
"""Information about a calculated release"""
|
|
is_final: bool
|
|
version: str
|
|
year: int
|
|
month: int
|
|
rc_number: int = 0
|
|
weeks_until_final: int = 0
|
|
is_dead_period: bool = False
|
|
|
|
|
|
def get_first_monday(year: int, month: int) -> datetime.date:
|
|
"""Get the first Monday of a given month and year"""
|
|
first_day = datetime.date(year, month, 1)
|
|
# Monday is 0 in weekday(), so days_until_monday = (7 - weekday()) % 7
|
|
days_until_monday = (7 - first_day.weekday()) % 7
|
|
return first_day + datetime.timedelta(days=days_until_monday)
|
|
|
|
|
|
def get_next_even_month(curdate: datetime.date) -> Tuple[int, int]:
|
|
"""Get the year and month of the next even-numbered month"""
|
|
year = curdate.year
|
|
month = curdate.month
|
|
|
|
if month % 2 == 0:
|
|
# Current month is even, next release is 2 months away
|
|
next_month = month + 2
|
|
if next_month > 12:
|
|
next_month -= 12
|
|
year += 1
|
|
else:
|
|
# Current month is odd, next release is next month
|
|
next_month = month + 1
|
|
|
|
return year, next_month
|
|
|
|
|
|
def calculate_info(curdate: datetime.date = None) -> ReleaseInfo:
|
|
"""Calculate release information based on the current date
|
|
|
|
Args:
|
|
curdate: Date to calculate from (defaults to today)
|
|
|
|
Returns:
|
|
ReleaseInfo with version details
|
|
"""
|
|
if curdate is None:
|
|
curdate = datetime.date.today()
|
|
|
|
year = curdate.year
|
|
month = curdate.month
|
|
day_of_week = curdate.weekday() # Monday = 0
|
|
day_of_month = curdate.day
|
|
|
|
# Check if this is a final release day (first Monday of even month)
|
|
if month % 2 == 0 and day_of_week == 0 and day_of_month <= 7:
|
|
# This is a final release
|
|
return ReleaseInfo(
|
|
is_final=True,
|
|
version=f'{year}.{month:02d}',
|
|
year=year,
|
|
month=month
|
|
)
|
|
|
|
# This is a release candidate - calculate based on next final release
|
|
next_year, next_month = get_next_even_month(curdate)
|
|
next_release_date = get_first_monday(next_year, next_month)
|
|
|
|
# Calculate weeks until next release
|
|
days_until_release = (next_release_date - curdate).days
|
|
weeks_until_release = (days_until_release + 6) // 7 # Round up
|
|
|
|
# Determine RC number based on weeks before release
|
|
if weeks_until_release > 6:
|
|
# Too far from release - dead period, no release
|
|
return ReleaseInfo(
|
|
is_final=False,
|
|
version='',
|
|
year=next_year,
|
|
month=next_month,
|
|
is_dead_period=True,
|
|
weeks_until_final=weeks_until_release
|
|
)
|
|
|
|
# Check if today is a Monday (releases only happen on Mondays every 2 weeks)
|
|
if day_of_week != 0: # Not a Monday
|
|
return ReleaseInfo(
|
|
is_final=False,
|
|
version='',
|
|
year=next_year,
|
|
month=next_month,
|
|
is_dead_period=True,
|
|
weeks_until_final=weeks_until_release
|
|
)
|
|
|
|
# Check if this Monday is a release Monday (every 2 weeks from final)
|
|
# Final is on first Monday, so release Mondays are at 0, 2, 4, 6 weeks
|
|
if weeks_until_release not in [0, 2, 4, 6]:
|
|
return ReleaseInfo(
|
|
is_final=False,
|
|
version='',
|
|
year=next_year,
|
|
month=next_month,
|
|
is_dead_period=True,
|
|
weeks_until_final=weeks_until_release
|
|
)
|
|
|
|
# RC numbering: rc1 is furthest (6 weeks), rc2 is middle (4 weeks),
|
|
# rc3 is closest (2 weeks), then final (0 weeks)
|
|
if weeks_until_release == 6:
|
|
rc_number = 1
|
|
elif weeks_until_release == 4:
|
|
rc_number = 2
|
|
elif weeks_until_release == 2:
|
|
rc_number = 3
|
|
else: # weeks_until_release == 0 - should not happen, finals caught earlier
|
|
rc_number = 1 # Fallback
|
|
|
|
version = f'{next_year}.{next_month:02d}-rc{rc_number}'
|
|
|
|
return ReleaseInfo(
|
|
is_final=False,
|
|
version=version,
|
|
year=next_year,
|
|
month=next_month,
|
|
rc_number=rc_number,
|
|
weeks_until_final=weeks_until_release
|
|
)
|
|
|
|
|
|
def generate_schedule():
|
|
"""Generate the next release schedule section"""
|
|
curdate = datetime.date.today()
|
|
info = calculate_info(curdate)
|
|
|
|
if info.is_dead_period:
|
|
# Find the next release
|
|
target_year = info.year
|
|
target_month = info.month
|
|
else:
|
|
if info.is_final:
|
|
# Just had a final, next is 2 months away
|
|
target_year, target_month = get_next_even_month(curdate)
|
|
else:
|
|
# In RC cycle, use the current target final release
|
|
target_year = info.year
|
|
target_month = info.month
|
|
|
|
# Calculate the schedule for the target release
|
|
final_date = get_first_monday(target_year, target_month)
|
|
|
|
# Calculate RC dates
|
|
rc3_date = final_date - datetime.timedelta(weeks=6)
|
|
rc2_date = final_date - datetime.timedelta(weeks=4)
|
|
rc1_date = final_date - datetime.timedelta(weeks=2)
|
|
|
|
schedule_text = f'''Next Release
|
|
------------
|
|
|
|
The next final release is scheduled for **{target_year}.{target_month:02d}**
|
|
on {final_date.strftime('%A, %B %d, %Y')}.
|
|
|
|
Release candidate schedule:
|
|
|
|
* **{target_year}.{target_month:02d}-rc3**: {rc3_date.strftime('%a %d-%b-%Y')}
|
|
* **{target_year}.{target_month:02d}-rc2**: {rc2_date.strftime('%a %d-%b-%Y')}
|
|
* **{target_year}.{target_month:02d}-rc1**: {rc1_date.strftime('%a %d-%b-%Y')}
|
|
* **{target_year}.{target_month:02d}** (Final): {final_date.strftime('%a %d-%b-%Y')}
|
|
|
|
'''
|
|
return schedule_text
|
|
|
|
|
|
def update_docs(info: ReleaseInfo, commit_sha: str = '',
|
|
docs_path: str = 'doc/develop/concept_releases.rst',
|
|
subject: str = '') -> bool:
|
|
"""
|
|
Update the documentation file with new information.
|
|
|
|
Args:
|
|
info: Release information to document
|
|
commit_sha: Git commit SHA for the release
|
|
docs_path: Path to the documentation file
|
|
subject: Commit subject/title for the release
|
|
|
|
Returns:
|
|
True if changes were made, False if already up-to-date
|
|
"""
|
|
if info.is_dead_period:
|
|
return False
|
|
|
|
# Create directory if it doesn't exist
|
|
docs_dir = os.path.dirname(docs_path)
|
|
if docs_dir:
|
|
os.makedirs(docs_dir, exist_ok=True)
|
|
|
|
# Create initial file if it doesn't exist
|
|
if not os.path.exists(docs_path):
|
|
with open(docs_path, 'w', encoding='utf-8') as f:
|
|
f.write('''.. SPDX-License-Identifier: GPL-2.0+
|
|
|
|
U-Boot Concept Releases
|
|
=======================
|
|
|
|
This document tracks all concept releases of U-Boot, including both final
|
|
releases and release candidates.
|
|
|
|
Release History
|
|
---------------
|
|
|
|
''')
|
|
|
|
# Read current content
|
|
try:
|
|
with open(docs_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
except FileNotFoundError:
|
|
print(f'Error: Could not read {docs_path}', file=sys.stderr)
|
|
return False
|
|
|
|
# Generate release entry
|
|
release_type = 'Final Release' if info.is_final else 'Release Candidate'
|
|
date_str = datetime.date.today().strftime('%Y-%m-%d')
|
|
|
|
|
|
new_entry = f'''**{info.version}** - {release_type}
|
|
:Date: {date_str}
|
|
:Commit: {commit_sha if commit_sha else 'N/A'}
|
|
:Subject: {subject if subject else 'N/A'}
|
|
|
|
'''
|
|
|
|
# Find Release History section to check for duplicates
|
|
lines = content.split('\n')
|
|
history_start = -1
|
|
for i, line in enumerate(lines):
|
|
if (line.strip() == 'Release History' and i + 1 < len(lines) and
|
|
lines[i + 1].strip().startswith('---')):
|
|
history_start = i + 3
|
|
break
|
|
# Check if this release is already documented in the Release History
|
|
if history_start != -1:
|
|
history_content = '\n'.join(lines[history_start:])
|
|
if f'**{info.version}** - ' in history_content:
|
|
return False # Already documented
|
|
|
|
# Update the 'Next Release' section and find release history
|
|
lines = content.split('\n')
|
|
next_release_pos = -1
|
|
history_pos = -1
|
|
|
|
# Find existing sections
|
|
for i, line in enumerate(lines):
|
|
if line.strip() == 'Next Release':
|
|
next_release_pos = i
|
|
elif (line.strip() == 'Release History' and i + 1 < len(lines) and
|
|
lines[i + 1].strip().startswith('---')):
|
|
history_pos = i + 3 # After header, separator, empty line
|
|
break
|
|
|
|
# Generate and update the Next Release section
|
|
next_schedule = generate_schedule()
|
|
next_schedule_lines = next_schedule.rstrip().split('\n')
|
|
|
|
if next_release_pos != -1:
|
|
# Find end of Next Release section
|
|
section_end = len(lines)
|
|
for j in range(next_release_pos + 1, len(lines)):
|
|
if lines[j].strip() == 'Release History':
|
|
section_end = j
|
|
break
|
|
if (j + 1 < len(lines) and lines[j + 1].strip() and
|
|
lines[j + 1].replace('-', '').strip() == ''):
|
|
# Found next section header
|
|
section_end = j + 1
|
|
break
|
|
|
|
# Replace the entire Next Release section
|
|
lines[next_release_pos:section_end] = next_schedule_lines
|
|
lines.insert(next_release_pos + len(next_schedule_lines), '')
|
|
|
|
# Recalculate release history position
|
|
history_pos = -1
|
|
for i, line in enumerate(lines):
|
|
if (line.strip() == 'Release History' and i + 1 < len(lines) and
|
|
lines[i + 1].strip().startswith('---')):
|
|
history_pos = i + 3
|
|
break
|
|
else:
|
|
# Insert Next Release section before Release History
|
|
if history_pos != -1:
|
|
insert_point = history_pos - 3
|
|
for line in reversed(next_schedule_lines + ['']):
|
|
lines.insert(insert_point, line)
|
|
history_pos += len(next_schedule_lines) + 1
|
|
|
|
# Add the release entry to Release History
|
|
if history_pos == -1:
|
|
# Fallback: append at the end
|
|
lines.append('')
|
|
lines.append(new_entry.rstrip())
|
|
else:
|
|
# Insert the new entry at the beginning of the release history
|
|
entry_lines = new_entry.rstrip().split('\n')
|
|
for entry_line in reversed(entry_lines):
|
|
lines.insert(history_pos, entry_line)
|
|
|
|
# Write the updated content
|
|
with open(docs_path, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(lines) + '\n')
|
|
|
|
return True
|
|
|
|
|
|
def update_makefile(info: ReleaseInfo, makefile_path: str = 'Makefile') -> bool:
|
|
"""
|
|
Update the Makefile with new version information.
|
|
|
|
Args:
|
|
info: Release information to write
|
|
makefile_path: Path to the Makefile
|
|
|
|
Returns:
|
|
True if changes were made, False if already up-to-date
|
|
"""
|
|
try:
|
|
with open(makefile_path, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
except FileNotFoundError:
|
|
print(f'Error: {makefile_path} not found', file=sys.stderr)
|
|
return False
|
|
|
|
lines = content.splitlines()
|
|
changes_made = False
|
|
|
|
for i, line in enumerate(lines):
|
|
if line.startswith('VERSION ='):
|
|
new_line = f'VERSION = {info.year}'
|
|
if lines[i] != new_line:
|
|
lines[i] = new_line
|
|
changes_made = True
|
|
elif line.startswith('PATCHLEVEL ='):
|
|
new_line = f'PATCHLEVEL = {info.month:02d}'
|
|
if lines[i] != new_line:
|
|
lines[i] = new_line
|
|
changes_made = True
|
|
elif line.startswith('SUBLEVEL ='):
|
|
new_line = 'SUBLEVEL ='
|
|
if lines[i] != new_line:
|
|
lines[i] = new_line
|
|
changes_made = True
|
|
elif line.startswith('EXTRAVERSION ='):
|
|
if info.is_final:
|
|
new_line = 'EXTRAVERSION ='
|
|
else:
|
|
new_line = f'EXTRAVERSION = -rc{info.rc_number}'
|
|
if lines[i] != new_line:
|
|
lines[i] = new_line
|
|
changes_made = True
|
|
|
|
if changes_made:
|
|
with open(makefile_path, 'w', encoding='utf-8') as f:
|
|
f.write('\n'.join(lines) + '\n')
|
|
|
|
return changes_made
|
|
|
|
|
|
def create_parser():
|
|
"""Create and return the argument parser"""
|
|
parser = argparse.ArgumentParser(
|
|
description='Calculate U-Boot release version')
|
|
parser.add_argument('--date', help='Date to calculate from (YYYY-MM-DD)')
|
|
parser.add_argument('--update-makefile', action='store_true',
|
|
help='Update Makefile with calculated version')
|
|
parser.add_argument('--makefile', default='Makefile',
|
|
help='Path to Makefile (default: Makefile)')
|
|
parser.add_argument('--update-release-docs', action='store_true',
|
|
help='Update release documentation with new release')
|
|
parser.add_argument(
|
|
'--release-docs',
|
|
default='doc/develop/concept_releases.rst',
|
|
help='Path to release docs (default: doc/develop/concept_releases.rst)')
|
|
parser.add_argument('--commit-sha', help='Git commit SHA for the release')
|
|
parser.add_argument('--subject', help='Commit subject for the release')
|
|
parser.add_argument('--format', choices=['version', 'json', 'shell'],
|
|
default='shell', help='Output format')
|
|
return parser
|
|
|
|
|
|
def main(args=None):
|
|
"""Main entry point for the script"""
|
|
if args is None:
|
|
parser = create_parser()
|
|
args = parser.parse_args()
|
|
|
|
# Parse date if provided
|
|
curdate = None
|
|
if args.date:
|
|
try:
|
|
curdate = datetime.datetime.strptime(
|
|
args.date, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
print(f'Error: Invalid date format \'{args.date}\'. Use YYYY-MM-DD',
|
|
file=sys.stderr)
|
|
return 1
|
|
|
|
# Calculate release info
|
|
info = calculate_info(curdate)
|
|
|
|
# Update Makefile if requested
|
|
if args.update_makefile:
|
|
if info.is_dead_period:
|
|
print('No release during dead period - Makefile not updated')
|
|
return 1 # Exit with error code to indicate no action taken
|
|
changes_made = update_makefile(info, args.makefile)
|
|
if not changes_made:
|
|
print(f'Makefile is already up-to-date for version {info.version}')
|
|
return 0
|
|
print(f'Updated Makefile for version {info.version}')
|
|
|
|
# Update documentation if requested
|
|
if args.update_release_docs:
|
|
if info.is_dead_period:
|
|
print('No release during dead period - Documentation not updated')
|
|
return 1 # Exit with error code to indicate no action taken
|
|
changes_made = update_docs(
|
|
info, args.commit_sha or '', args.release_docs, args.subject or '')
|
|
if changes_made:
|
|
print(f'Updated documentation for version {info.version}')
|
|
else:
|
|
print(f'Documentation already contains version {info.version}')
|
|
return 0
|
|
|
|
# Handle dead period - no release should be generated
|
|
if info.is_dead_period:
|
|
if args.format == 'version':
|
|
print('NO_RELEASE')
|
|
elif args.format == 'json':
|
|
output = {
|
|
'is_dead_period': True,
|
|
'weeks_until_final': info.weeks_until_final,
|
|
'next_release_year': info.year,
|
|
'next_release_month': info.month
|
|
}
|
|
print(json.dumps(output))
|
|
elif args.format == 'shell':
|
|
print('IS_DEAD_PERIOD=true')
|
|
print(f'WEEKS_UNTIL_FINAL={info.weeks_until_final}')
|
|
print(f'NEXT_RELEASE_YEAR={info.year}')
|
|
print(f'NEXT_RELEASE_MONTH={info.month:02d}')
|
|
return 0
|
|
|
|
# Output results
|
|
if args.format == 'version':
|
|
print(info.version)
|
|
elif args.format == 'json':
|
|
output = {
|
|
'is_final': info.is_final,
|
|
'version': info.version,
|
|
'year': info.year,
|
|
'month': info.month,
|
|
}
|
|
if not info.is_final:
|
|
output['rc_number'] = info.rc_number
|
|
output['weeks_until_final'] = info.weeks_until_final
|
|
print(json.dumps(output))
|
|
elif args.format == 'shell':
|
|
print(f"IS_FINAL={'true' if info.is_final else 'false'}")
|
|
print(f'VERSION={info.version}')
|
|
print(f'YEAR={info.year}')
|
|
print(f'MONTH={info.month:02d}')
|
|
if not info.is_final:
|
|
print(f'RC_NUMBER={info.rc_number}')
|
|
print(f'WEEKS_UNTIL_FINAL={info.weeks_until_final}')
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|