Files
u-boot/scripts/release_version.py
Simon Glass 08faed68c3 scripts: Fix RST formatting in release_version.py
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>
2025-09-22 11:23:04 -06:00

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())