diff --git a/scripts/meta-arm-release.py b/scripts/meta-arm-release.py new file mode 100644 index 00000000..518da03f --- /dev/null +++ b/scripts/meta-arm-release.py @@ -0,0 +1,731 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Arm Limited +# SPDX-License-Identifier: MIT +"""Create, publish and announce meta-arm release tags.""" + +import argparse +from datetime import date, datetime +import os +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from email.message import EmailMessage +from pathlib import Path + +SUPPORT_URL = 'https://docs.yoctoproject.org/ref-manual/release-process.html' +VERSION_RE = re.compile(r'^(\d+)\.(\d+)(?:\.(\d+))?$') +PLATFORM_TAG_RE = re.compile(r'^[A-Z][A-Z0-9_-]*-[0-9]{4}\.[0-9]{2}\.[0-9]{2}$') +DEFAULT_LTS = ('scarthgap', 'wrynose') + + +class ReleaseError(Exception): + pass + + +def run(*argv, check=True): + result = subprocess.run(argv, text=True, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + if check and result.returncode: + raise ReleaseError(f"{' '.join(argv)}: {result.stderr.strip()}") + return result + + +def git(*args, check=True): + return run('git', *args, check=check) + + +def series_number(version): + match = VERSION_RE.fullmatch(version) + if not match or match.group(3) is not None or int(match.group(2)) > 3: + raise ReleaseError(f'Invalid initial release version: {version}') + return int(match.group(1)) * 4 + int(match.group(2)) + + +def series_from_number(number): + return f'{number // 4}.{number % 4}' + + +def previous_series(version): + number = series_number(version) + if number == 0: + raise ReleaseError('No previous release version') + return series_from_number(number - 1) + + +def next_patch(version): + match = VERSION_RE.fullmatch(version) + if not match: + raise ReleaseError(f'Invalid version: {version}') + return f'{match.group(1)}.{match.group(2)}.{int(match.group(3) or 0) + 1}' + + +def platform_tag(name, release_date=None): + """Build a platform tag, validating both platform name and calendar date.""" + if not re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]*', name): + raise ReleaseError(f'Invalid platform name: {name}') + if release_date is None: + release_date = date.today().strftime('%Y.%m.%d') + try: + datetime.strptime(release_date, '%Y.%m.%d') + except ValueError as exc: + raise ReleaseError('--date must be a valid YYYY.MM.DD date') from exc + if not re.fullmatch(r'\d{4}\.\d{2}\.\d{2}', release_date): + raise ReleaseError('--date must use YYYY.MM.DD') + return f'{name.upper()}-{release_date}' + + +@dataclass +class BranchStatus: + branch: str + series: str + last_tag: str | None + commits: int | None + next_tag: str + + +class ReleaseManager: + def __init__(self, remote='yoctoproject', recipient='meta-arm@lists.yoctoproject.org', + lts=DEFAULT_LTS, execute=False, edit=False, as_of=None, force_lts=False, mailer='auto'): + self.remote = remote + self.recipient = recipient + self.lts = tuple(lts) + self.execute = execute + self.edit = edit + self.as_of = as_of + self.force_lts = force_lts + if mailer not in ('auto', 'sendmail', 'mutt'): + raise ReleaseError(f'Invalid mailer: {mailer}') + self.mailer = mailer + self._branches = None + self._series = None + + def branch_ref(self, branch): + return f'refs/remotes/{self.remote}/{branch}' + + def branches(self): + if self._branches is None: + refs = git('for-each-ref', '--format=%(refname:strip=3)', + f'refs/remotes/{self.remote}/').stdout.splitlines() + self._branches = sorted(b for b in refs if b not in ('master', 'testing') and '/' not in b) + if not self._branches: + raise ReleaseError(f'No release branches under {self.remote}') + return self._branches + + def require_branch(self, branch): + if branch not in self.branches(): + raise ReleaseError(f'Unknown or excluded upstream branch: {branch}') + + @staticmethod + def ancestor(older, newer): + return git('merge-base', '--is-ancestor', older, newer, check=False).returncode == 0 + + @staticmethod + def tag_commit(tag): + result = git('rev-parse', '--verify', '-q', f'refs/tags/{tag}^{{commit}}', check=False) + return result.stdout.strip() if result.returncode == 0 else None + + @staticmethod + def tag_object(tag): + result = git('rev-parse', '--verify', '-q', f'refs/tags/{tag}', check=False) + return result.stdout.strip() if result.returncode == 0 else None + + def numeric_tags(self): + return [tag for tag in git('tag', '-l').stdout.splitlines() if VERSION_RE.fullmatch(tag)] + + def discover_series(self): + """Use tags that lie on a branch, not inherited tags on its fork history. + + A branch's initial release can be at the fork itself; in that case, + resolve shared tags by proximity and fail closed if ambiguous. + """ + if self._series is not None: + return self._series + branches = self.branches() + tags = self.numeric_tags() + forks = {} + for branch in branches: + result = git('merge-base', self.branch_ref(branch), self.branch_ref('master')) + forks[branch] = result.stdout.strip() + + # For every series, assign it to the branch whose tip is nearest + # to its latest reachable tag. Ties cannot establish ownership. + candidates = {} + for tag in tags: + match = VERSION_RE.fullmatch(tag) + series = f'{match.group(1)}.{match.group(2)}' + for branch in branches: + if not self.ancestor(f'refs/tags/{tag}', self.branch_ref(branch)): + continue + distance = int(git('rev-list', '--count', + f'{tag}..{self.branch_ref(branch)}').stdout) + current = candidates.setdefault(series, {}) + if branch not in current or distance < current[branch][0] or ( + distance == current[branch][0] and + (int(match.group(3) or 0) > current[branch][1])): + current[branch] = (distance, int(match.group(3) or 0), tag) + + owners = {} + for series, choices in candidates.items(): + ranked = sorted((v[0], -v[1], b) for b, v in choices.items()) + if not ranked: + continue + # If multiple branches are tied, avoid guessing ownership. + if len(ranked) > 1 and ranked[0][:2] == ranked[1][:2]: + raise ReleaseError(f'Ambiguous ownership for {series}: {ranked[0][2]}, {ranked[1][2]}') + owners[series] = ranked[0][2] + + by_branch = {} + for series, branch in owners.items(): + if branch in by_branch: + raise ReleaseError(f'Multiple version series appear to belong to {branch}') + by_branch[branch] = series + + if not by_branch: + raise ReleaseError('No release version anchors found') + + # Infer only unreleased branches, after the highest established series. + missing = [b for b in branches if b not in by_branch] + if missing: + latest = max(series_number(s) for s in by_branch.values()) + ordered = sorted(missing, key=lambda b: int(git('show', '-s', '--format=%ct', forks[b]).stdout)) + for earlier, later in zip(ordered, ordered[1:]): + if not self.ancestor(forks[earlier], forks[later]) or forks[earlier] == forks[later]: + raise ReleaseError(f'Cannot order unreleased branches: {earlier}, {later}') + for branch in ordered: + latest += 1 + by_branch[branch] = series_from_number(latest) + self._series = by_branch + return by_branch + + def latest_tag(self, branch): + series = self.discover_series()[branch] + choices = [] + for tag in self.numeric_tags(): + match = VERSION_RE.fullmatch(tag) + if f'{match.group(1)}.{match.group(2)}' != series: + continue + if self.ancestor(f'refs/tags/{tag}', self.branch_ref(branch)): + choices.append((int(match.group(3) or 0), tag)) + return max(choices)[1] if choices else None + + def status(self, branch): + self.require_branch(branch) + series = self.discover_series()[branch] + last = self.latest_tag(branch) + if not last: + return BranchStatus(branch, series, None, None, f'Initial {series}') + count = int(git('rev-list', '--count', f'{last}..{self.branch_ref(branch)}').stdout) + return BranchStatus(branch, series, last, count, next_patch(last) if count else '-') + + def show_status(self, branches): + print(f'{"Branch":<18} {"Version":<9} {"Last-tag":<14} {"Commits":<10} Next-tag') + for branch in branches: + state = self.status(branch) + print(f'{branch:<18} {state.series:<9} {state.last_tag or "(none)":<14} ' + f'{str(state.commits) if state.commits is not None else "-":<10} {state.next_tag}') + + def create_tags(self, branch, version, ref, kind): + self.require_branch(branch) + if not VERSION_RE.fullmatch(version): + raise ReleaseError(f'Invalid tag version: {version}') + target = git('rev-parse', '--verify', f'{ref}^{{commit}}').stdout.strip() + if not self.ancestor(target, self.branch_ref(branch)): + raise ReleaseError(f'{ref} is not on {branch}') + for tag in (version, f'yocto-{version}'): + old = self.tag_commit(tag) + if old and old != target: + raise ReleaseError(f'{tag} already points to another commit') + print(f'{branch}: {version} -> {target[:12]} ({kind})') + for tag in (version, f'yocto-{version}'): + if self.tag_object(tag): + print(f' Exists: {tag}') + elif self.execute: + git('tag', '-s', tag, '-m', f'{kind} for Yocto Project {version} ({branch})', target) + print(f' Created: {tag}') + else: + print(f' DRY RUN: create {tag}') + self._series = None + + def release(self, branch, version): + self.require_branch(branch) + expected = self.discover_series()[branch] + if version != expected: + raise ReleaseError(f'Expected {expected} for {branch}, got {version}') + ref = self.branch_ref(branch) + if self.as_of: + ref = git('rev-list', '--first-parent', '--before', f'{self.as_of} 23:59:59', + '-1', ref).stdout.strip() + if not ref: + raise ReleaseError(f'No commit on {branch} before {self.as_of}') + self.create_tags(branch, version, ref, 'Initial release') + + def update(self, branches): + created = [] + for branch in branches: + state = self.status(branch) + if not state.last_tag: + print(f'{branch}: initial release {state.series} missing; skipping') + elif not state.commits: + print(f'{branch}: no new commits; skipping') + else: + self.create_tags(branch, state.next_tag, self.branch_ref(branch), 'Update') + created.append((branch, state.next_tag)) + return created + + def platform(self, name, ref, release_date=None): + """Create a signed platform tag without requiring release branch discovery.""" + tag = platform_tag(name, release_date) + target = git('rev-parse', '--verify', f'{ref}^{{commit}}').stdout.strip() + existing = self.tag_object(tag) + if existing: + if self.tag_commit(tag) != target: + raise ReleaseError(f'{tag} already exists at another commit') + print(f'Platform tag already exists: {tag} -> {target[:12]}') + return tag + print(f'Platform: {name}\nCommit: {target[:12]}\nTag: {tag}') + if self.execute: + message = f'{name.upper()} platform release ({tag.rsplit("-", 1)[1]})\n\nRelease of the {name} platform at commit {target}.' + git('tag', '-s', tag, '-m', message, target) + print(f'Created signed tag: {tag}') + else: + print(f'DRY RUN: Would create signed tag {tag}.') + return tag + + def tags_for(self, branches): + series_map = self.discover_series() + selected = branches or self.branches() + result = set() + for branch in selected: + self.require_branch(branch) + series = series_map[branch] + for tag in self.numeric_tags(): + match = VERSION_RE.fullmatch(tag) + if f'{match.group(1)}.{match.group(2)}' != series: + continue + if not self.ancestor(f'refs/tags/{tag}', self.branch_ref(branch)): + continue + paired = f'yocto-{tag}' + if not self.tag_commit(paired) or self.tag_commit(paired) != self.tag_commit(tag): + raise ReleaseError(f'Missing or mismatched paired tag: {paired}') + result.update((tag, paired)) + return sorted(result) + + def push(self, selections=(), all_tags=False): + """Push selected tags, or all recognized release tags, without overwrites. + + For compatibility, a selection may be an existing release branch. + Explicit tag names need not match the recognized tag patterns. + """ + if all_tags: + tags = git('tag', '-l').stdout.splitlines() + elif selections: + tags = set() + for selection in selections: + if self.tag_object(selection): + tags.add(selection) + elif selection in self.branches(): + tags.update(self.tags_for((selection,))) + else: + raise ReleaseError(f'Unknown tag or release branch: {selection}') + tags = sorted(tags) + else: + tags = [t for t in git('tag', '-l').stdout.splitlines() + if VERSION_RE.fullmatch(t) + or (t.startswith('yocto-') and VERSION_RE.fullmatch(t[6:])) + or PLATFORM_TAG_RE.fullmatch(t)] + if not tags: + print('No matching release tags') + return + remote = {} + for line in git('ls-remote', '--tags', '--refs', self.remote).stdout.splitlines(): + sha, ref = line.split() + remote[ref.removeprefix('refs/tags/')] = sha + pending = [] + published = 0 + for tag in tags: + local = self.tag_object(tag) + old = remote.get(tag) + if old is None: + pending.append(tag) + elif old == local: + published += 1 + else: + raise ReleaseError(f'Tag conflict on {self.remote}: {tag} local={local} remote={old}') + print(f'Upstream: {self.remote}\nAlready published: {published}\nNew tags: {len(pending)}') + for tag in pending: + print(f' {tag}') + if pending and self.execute: + git('push', '--atomic', self.remote, + *(f'refs/tags/{tag}:refs/tags/{tag}' for tag in pending)) + print(f'Published {len(pending)} tags.') + elif not self.execute: + print('DRY RUN: No tags pushed.') + + def lts_description(self, branch): + if branch in self.lts or self.force_lts: + opening = ('This is a Long Term Support (LTS) release. meta-arm generally aims ' + 'to follow the Yocto Project\'s LTS support lifecycle on a best-effort ' + 'basis, with maintenance primarily focused on bug fixes and security ' + 'fixes. CI testing is performed on supported configurations as resources permit.') + else: + opening = ('This is NOT a Long Term Support (LTS) release. meta-arm generally aims ' + 'to follow the Yocto Project\'s release support lifecycle on a best-effort ' + 'basis. For non-LTS releases, this means approximately seven months of ' + 'limited maintenance, primarily consisting of bug fixes and security fixes, ' + 'with minimal CI testing.') + if self.lts: + names = ', '.join(self.lts[:-1]) + (' and ' if len(self.lts) > 1 else '') + self.lts[-1] + opening += f'\n\nThe currently supported LTS branches are {names}.' + return opening + ('\n\nFor more information about the Yocto Project\'s release ' + f'lifecycle and LTS policy, see:\n{SUPPORT_URL}') + + def announcement(self, branch, version): + self.require_branch(branch) + series = self.discover_series()[branch] + if version != series and not re.fullmatch(re.escape(series) + r'\.\d+', version): + raise ReleaseError(f'{version} does not belong to {branch} ({series})') + if not self.tag_commit(version) or self.tag_commit(version) != self.tag_commit(f'yocto-{version}'): + raise ReleaseError(f'Missing or mismatched release tag pair: {version}') + if not self.ancestor(f'refs/tags/{version}', self.branch_ref(branch)): + raise ReleaseError(f'{version} is not on {branch}') + lts_text = self.lts_description(branch) + if version == series: + previous = previous_series(version) + if not self.tag_commit(previous): + raise ReleaseError(f'Missing previous initial release tag {previous}') + count = git('rev-list', '--count', f'{previous}..{version}').stdout.strip() + subject = f'meta-arm {branch} release' + body = (f'meta-arm has released the {branch} support branch, tagged as {version}.\n\n' + f'{lts_text}\n\nThis release contains {count} commits since the previous ' + f'Yocto Project release, {previous}.') + else: + patch = int(version.rsplit('.', 1)[1]) + previous = series if patch == 1 else f'{series}.{patch - 1}' + if not self.tag_commit(previous) or not self.ancestor(previous, version): + raise ReleaseError(f'Missing or non-ancestor previous tag: {previous}') + log = git('log', '--reverse', '--format=%h %s', f'{previous}..{version}').stdout.strip() + count = int(git('rev-list', '--count', f'{previous}..{version}').stdout) + if not count: + raise ReleaseError('No commits in update release') + dates = git('log', '--reverse', '--format=%cs', f'{previous}..{version}').stdout.splitlines() + first, last = dates[0], git('log', '-1', '--format=%cs', version).stdout.strip() + subject = f'meta-arm {branch} update' + body = (f'meta-arm has released an updated {branch} support tag, {version}.\n\n' + f'{lts_text}\n\nThis release contains {count} commits between {first} and {last}.\n\n' + 'Changes included in this release:\n\n' + + '\n'.join(' ' + line for line in log.splitlines())) + body += (f'\n\nThe release is available under the {version} and yocto-{version} tags.\n' + 'Both tags are GPG signed.\n\nThanks,\nThe meta-arm team') + return subject, body + + @staticmethod + def editor_command(): + # git var GIT_EDITOR already respects GIT_EDITOR, VISUAL, EDITOR and core.editor. + result = git('var', 'GIT_EDITOR', check=False) + return result.stdout.strip() if result.returncode == 0 and result.stdout.strip() else 'vi' + + def edit_message(self, subject, body): + original = f'Subject: {subject}\n\n{body}\n' + with tempfile.TemporaryDirectory(prefix='meta-arm-release-') as directory: + path = Path(directory) / 'announcement.txt' + path.write_text(original, encoding='utf-8') + editor = self.editor_command() + print(f'Opening announcement in {editor}...', flush=True) + # Interactive editors must inherit stdin, stdout, and stderr. + # Git editor settings may contain shell quoting and arguments. + try: + subprocess.run( + ['sh', '-c', f'{editor} "$1"', 'editor', str(path)], + check=True, + ) + except subprocess.CalledProcessError as exc: + raise ReleaseError('Editor exited unsuccessfully; email not sent') from exc + edited = path.read_text(encoding='utf-8') + if edited == original: + print('Email unchanged; not sending.') + return None + header, separator, text = edited.partition('\n\n') + if not separator or not header.startswith('Subject: '): + raise ReleaseError('Edited email must begin with Subject: followed by a blank line') + new_subject = header.removeprefix('Subject: ').strip() + if not new_subject or '\r' in new_subject or '\n' in new_subject or not text.strip(): + raise ReleaseError('Invalid edited email subject or body') + return new_subject, text.rstrip('\n') + + def mail_backend(self): + """Resolve the requested mailer without silently changing forced choices.""" + sendmail = shutil.which('sendmail') + if not sendmail and os.access('/usr/sbin/sendmail', os.X_OK): + sendmail = '/usr/sbin/sendmail' + mutt = shutil.which('mutt') + + if self.mailer == 'sendmail': + return sendmail + if self.mailer == 'mutt': + return mutt + return sendmail or mutt + + def send(self, subject, body): + backend = self.mail_backend() + if not backend: + raise ReleaseError(f'Requested mailer {self.mailer!r} is unavailable' if self.mailer != 'auto' + else 'Neither sendmail nor mutt is available') + if os.path.basename(backend) == 'mutt': + subprocess.run([backend, '-s', subject, '--', self.recipient], + input=body + '\n', text=True, check=True) + else: + message = EmailMessage() + message['To'] = self.recipient + message['Subject'] = subject + message.set_content(body) + subprocess.run([backend, '-t', '-i'], input=message.as_bytes(), check=True) + + def email(self, branch, version): + subject, body = self.announcement(branch, version) + if self.edit: + changed = self.edit_message(subject, body) + if changed is None: + return + subject, body = changed + print(f'\nSubject: {subject}\n\n{body}\n') + if not self.execute: + print('DRY RUN: Email not sent.') + return + for tag in (version, f'yocto-{version}'): + output = git('ls-remote', '--tags', '--refs', self.remote, f'refs/tags/{tag}').stdout + entries = dict(line.split()[::-1] for line in output.splitlines()) + if entries.get(f'refs/tags/{tag}') != self.tag_object(tag): + raise ReleaseError(f'{tag} is not published identically on {self.remote}') + self.send(subject, body) + print('Email submitted successfully.') + + def check(self): + print('meta-arm release environment\n' + '-' * 48) + errors = 0 + inside = git('rev-parse', '--is-inside-work-tree', check=False).returncode == 0 + print(f'Git repository: {"OK" if inside else "FAILED"}') + errors += not inside + url = git('remote', 'get-url', self.remote, check=False) + print(f'Upstream remote: {self.remote} ({url.stdout.strip() or "missing"})') + reachable = url.returncode == 0 and git('ls-remote', '--heads', self.remote, check=False).returncode == 0 + print(f'Upstream reachable: {"OK" if reachable else "FAILED"}') + errors += not reachable + master = git('rev-parse', '--verify', '-q', f'{self.branch_ref("master")}^{{commit}}', check=False).returncode == 0 + print(f'Master branch: {"OK" if master else "MISSING"}') + errors += not master + if master: + print(f'Release branches: {len(self.branches())}') + identity = (git('config', 'user.name', check=False).stdout.strip(), + git('config', 'user.email', check=False).stdout.strip()) + print(f'Git identity: {identity[0]} <{identity[1]}>' if all(identity) + else 'Git identity: NOT CONFIGURED') + errors += not all(identity) + signing = git('config', 'user.signingkey', check=False).stdout.strip() + print(f'GPG signing key: {signing or "Git default (not verified)"}') + mailer_path = self.mail_backend() + mailer_note = ' (forced)' if self.mailer != 'auto' else ' (auto)' + print(f'Email delivery: {(mailer_path or "Not available") + mailer_note}') + print(f'Email recipient: {self.recipient}') + print(f'Editor: {self.editor_command()}') + print(f'LTS branches: {", ".join(self.lts)}') + print('Environment ready.' if not errors else f'Environment check failed: {errors} error(s).') + return not errors + + +class FriendlyArgumentParser(argparse.ArgumentParser): + """Display the curated usage page without argparse's duplicate sections.""" + + def format_help(self): + return self.description + '\n' + + +def parser(): + """Build a CLI with a compact, example-driven help page.""" + help_text = """\ +Usage: meta-arm-release.py COMMAND [ARGUMENTS] [OPTIONS] + +Commands: + check Check the release environment + status [BRANCH...] Show release status and pending commits + release BRANCH VERSION Create initial signed release tags (x.y) + update [BRANCH...] Tag branches containing new commits (x.y.z) + tag BRANCH VERSION REF Tag a specific historical commit + platform NAME REF Create a signed platform release tag + push [TAG|BRANCH...] Publish missing release tags to upstream + email BRANCH VERSION Preview or send an announcement + all BRANCH VERSION Initial release, updates, push and announcements + +Options: + -h, --help, -? Display this help + -n, --dry-run Preview operations (default) + -x, --execute Perform requested operations + -e, --edit Edit announcements using your configured editor + --lts Treat specified release as LTS + --as-of YYYY-MM-DD Select historical first-parent commit by date + --date YYYY.MM.DD Platform tag date (default: today) + --remote NAME Upstream remote (default: yoctoproject) + --email-to ADDRESS Override announcement recipient + --mailer {auto,sendmail,mutt} Select email delivery method (default: auto) + --lts-branches "A B" Set supported LTS branches + +Examples: + meta-arm-release.py check + meta-arm-release.py status + meta-arm-release.py status scarthgap wrynose + meta-arm-release.py release blacksail 6.1 + meta-arm-release.py release blacksail 6.1 --execute + meta-arm-release.py update scarthgap whinlatter --execute + meta-arm-release.py tag wrynose 6.0 COMMIT --execute + meta-arm-release.py platform corstone1000 HEAD + meta-arm-release.py platform corstone1000 HEAD --date 2026.10.01 --execute + meta-arm-release.py push CORSTONE1000-2026.10.01 --execute + meta-arm-release.py push + meta-arm-release.py push --execute + meta-arm-release.py email blacksail 6.1 + meta-arm-release.py email blacksail 6.1 --edit + meta-arm-release.py email blacksail 6.1 --edit --execute + meta-arm-release.py email blacksail 6.1 --mailer mutt --execute + meta-arm-release.py all blacksail 6.1 --execute + +Notes: + * Dry-run is the default; --execute does not ask for confirmation. + * Email previews require no mail program; automatic delivery prefers + sendmail and falls back to mutt. Use --mailer to force a backend. + * Editor selection follows Git configuration (including VISUAL/EDITOR). + * Release branches must exist upstream; branches containing '/' are skipped. + * Existing tags are never overwritten; only missing tags are pushed. + * Push without arguments discovers Yocto and platform release tags. + * Push operations use only the configured upstream remote. + * The script never checks out branches. + +Environment variables: + META_ARM_RELEASE_REMOTE Override upstream remote + META_ARM_RELEASE_EMAIL Override announcement recipient + META_ARM_LTS_BRANCHES Space-separated LTS branches + META_ARM_MAILER auto, sendmail, or mutt +""" + p = FriendlyArgumentParser( + prog='meta-arm-release.py', + description=help_text, + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=False, + ) + p.add_argument('-h', '--help', '-?', action='help', help=argparse.SUPPRESS) + p.add_argument('--execute', '-x', action='store_true', help=argparse.SUPPRESS) + p.add_argument('--dry-run', '-n', action='store_true', help=argparse.SUPPRESS) + p.add_argument('--edit', '-e', action='store_true', help=argparse.SUPPRESS) + p.add_argument('--lts', action='store_true', help=argparse.SUPPRESS) + p.add_argument('--as-of', metavar='YYYY-MM-DD', help=argparse.SUPPRESS) + p.add_argument('--date', metavar='YYYY.MM.DD', help=argparse.SUPPRESS) + p.add_argument('--remote', default=os.getenv('META_ARM_RELEASE_REMOTE', 'yoctoproject'), + help=argparse.SUPPRESS) + p.add_argument('--email-to', default=os.getenv('META_ARM_RELEASE_EMAIL', 'meta-arm@lists.yoctoproject.org'), + help=argparse.SUPPRESS) + p.add_argument('--mailer', choices=('auto', 'sendmail', 'mutt'), + default=os.getenv('META_ARM_MAILER', 'auto'), help=argparse.SUPPRESS) + p.add_argument('--lts-branches', default=os.getenv('META_ARM_LTS_BRANCHES', 'scarthgap wrynose'), + help=argparse.SUPPRESS) + sub = p.add_subparsers(dest='command', required=True, metavar='COMMAND') + sub.add_parser('check', add_help=False) + q = sub.add_parser('status', add_help=False) + q.add_argument('branches', nargs='*') + q = sub.add_parser('release', add_help=False) + q.add_argument('branch') + q.add_argument('version') + q = sub.add_parser('update', add_help=False) + q.add_argument('branches', nargs='*') + q = sub.add_parser('tag', add_help=False) + q.add_argument('branch') + q.add_argument('version') + q.add_argument('ref') + q = sub.add_parser('platform', add_help=False) + q.add_argument('name') + q.add_argument('ref') + q = sub.add_parser('push', add_help=False) + q.add_argument('selections', nargs='*') + q = sub.add_parser('email', add_help=False) + q.add_argument('branch') + q.add_argument('version') + q = sub.add_parser('all', add_help=False) + q.add_argument('branch') + q.add_argument('version') + return p + + +def main(argv=None): + p = parser() + # Support global options either before or after the subcommand. + args = list(sys.argv[1:] if argv is None else argv) + if not args or any(token in ('-h', '--help', '-?') for token in args): + p.print_help() + return 0 + globals_with_value = ('--as-of', '--date', '--remote', '--email-to', '--lts-branches', '--mailer') + globals_flags = ('--execute', '-x', '--dry-run', '-n', '--edit', '-e', '--lts') + front, rest = [], [] + i = 0 + while i < len(args): + token = args[i] + if token in globals_with_value: + if i + 1 >= len(args): + p.error(f'{token} requires a value') + front.extend(args[i:i+2]); i += 2 + elif token in globals_flags: + front.append(token); i += 1 + else: + rest.append(token); i += 1 + ns = p.parse_args(front + rest) + if ns.email_to.find('\n') >= 0 or ns.email_to.find('\r') >= 0: + p.error('Invalid email recipient') + if ns.date and ns.command != 'platform': + p.error('--date only applies to platform') + if ns.edit and ns.command not in ('email', 'all'): + p.error('--edit only applies to email and all') + if ns.as_of and not re.fullmatch(r'\d{4}-\d{2}-\d{2}', ns.as_of): + p.error('--as-of requires YYYY-MM-DD') + manager = ReleaseManager(ns.remote, ns.email_to, ns.lts_branches.split(), + ns.execute and not ns.dry_run, ns.edit, ns.as_of, ns.lts, ns.mailer) + if ns.command == 'check': + return 0 if manager.check() else 1 + if git('rev-parse', '--is-inside-work-tree', check=False).returncode: + raise ReleaseError('Not in a Git repository') + if ns.command == 'status': + manager.show_status(ns.branches or manager.branches()) + elif ns.command == 'release': + manager.release(ns.branch, ns.version) + elif ns.command == 'update': + manager.update(ns.branches or manager.branches()) + elif ns.command == 'tag': + manager.require_branch(ns.branch) + expected = manager.discover_series()[ns.branch] + if ns.version != expected and not re.fullmatch(re.escape(expected) + r'\.\d+', ns.version): + raise ReleaseError(f'Wrong version series for {ns.branch}: {ns.version}') + manager.create_tags(ns.branch, ns.version, ns.ref, 'Release') + elif ns.command == 'platform': + manager.platform(ns.name, ns.ref, ns.date) + elif ns.command == 'push': + manager.push(ns.selections) + elif ns.command == 'email': + manager.email(ns.branch, ns.version) + elif ns.command == 'all': + manager.release(ns.branch, ns.version) + created = [(ns.branch, ns.version)] + created += manager.update([b for b in manager.branches() if b != ns.branch]) + if manager.execute: + manager.push() + for branch, version in created: + manager.email(branch, version) + else: + print('DRY RUN: No tags created, pushed or emails sent.') + return 0 + + +if __name__ == '__main__': + try: + sys.exit(main()) + except (ReleaseError, subprocess.CalledProcessError) as error: + print(f'ERROR: {error}', file=sys.stderr) + sys.exit(1) diff --git a/scripts/test_meta_arm_release.py b/scripts/test_meta_arm_release.py new file mode 100644 index 00000000..8c0d3c39 --- /dev/null +++ b/scripts/test_meta_arm_release.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Arm Limited +# SPDX-License-Identifier: MIT +"""Offline unit and integration tests for meta-arm-release.py.""" + +import importlib.util +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +SCRIPT = Path(__file__).with_name('meta-arm-release.py') +spec = importlib.util.spec_from_file_location('meta_arm_release', SCRIPT) +release = importlib.util.module_from_spec(spec) +import sys +sys.modules[spec.name] = release +spec.loader.exec_module(release) + + +class VersionTests(unittest.TestCase): + def test_initial_release_sequence(self): + self.assertEqual(release.previous_series('6.0'), '5.3') + self.assertEqual(release.previous_series('6.1'), '6.0') + self.assertEqual(release.series_from_number(release.series_number('5.3') + 1), '6.0') + + def test_patch_version(self): + self.assertEqual(release.next_patch('5.0'), '5.0.1') + self.assertEqual(release.next_patch('5.0.4'), '5.0.5') + + def test_bad_version(self): + with self.assertRaises(release.ReleaseError): + release.series_number('6.4') + + +class GitFixture(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.repo = Path(self.temp.name) + self.previous = Path.cwd() + os.chdir(self.repo) + self.addCleanup(os.chdir, self.previous) + self.git('init', '-q', '-b', 'master') + self.git('config', 'user.name', 'Test Maintainer') + self.git('config', 'user.email', 'test@example.com') + self.commit('initial') + self.remote = self.repo / 'upstream.git' + subprocess.check_call(['git', 'init', '-q', '--bare', str(self.remote)]) + self.git('remote', 'add', 'yoctoproject', str(self.remote)) + self.git('update-ref', 'refs/remotes/yoctoproject/master', self.git('rev-parse', 'master')) + + def git(self, *args): + return subprocess.check_output(['git', *args], text=True).strip() + + def commit(self, subject): + with (self.repo / 'history.txt').open('a') as stream: + stream.write(subject + '\n') + self.git('add', 'history.txt') + self.git('commit', '-q', '-m', subject) + + def branch(self, name): + self.git('branch', name) + self.git('update-ref', f'refs/remotes/yoctoproject/{name}', self.git('rev-parse', name)) + + def refresh(self, name): + self.git('update-ref', f'refs/remotes/yoctoproject/{name}', self.git('rev-parse', name)) + + +class ReleaseIntegrationTests(GitFixture): + def test_existing_tag_and_update(self): + self.branch('scarthgap') + self.git('checkout', '-q', 'scarthgap') + self.git('tag', '-a', '5.0', '-m', 'initial') + self.git('tag', '-a', 'yocto-5.0', '-m', 'initial') + self.commit('fix one') + self.commit('fix two') + self.refresh('scarthgap') + self.git('checkout', '-q', 'master') + manager = release.ReleaseManager() + state = manager.status('scarthgap') + self.assertEqual((state.series, state.last_tag, state.commits, state.next_tag), + ('5.0', '5.0', 2, '5.0.1')) + + def test_no_updates(self): + self.branch('scarthgap') + self.git('tag', '-a', '5.0', '-m', 'initial') + self.git('tag', '-a', 'yocto-5.0', '-m', 'initial') + state = release.ReleaseManager().status('scarthgap') + self.assertEqual(state.commits, 0) + self.assertEqual(state.next_tag, '-') + + def test_announcement_initial_count(self): + self.branch('wrynose') + self.git('tag', '-a', '6.0', '-m', 'initial') + self.git('tag', '-a', 'yocto-6.0', '-m', 'initial') + self.commit('master change') + self.branch('blacksail') + self.git('tag', '-a', '6.1', '-m', 'initial') + self.git('tag', '-a', 'yocto-6.1', '-m', 'initial') + manager = release.ReleaseManager() + subject, body = manager.announcement('blacksail', '6.1') + self.assertEqual(subject, 'meta-arm blacksail release') + self.assertIn('1 commits since the previous Yocto Project release, 6.0', body) + self.assertIn('The meta-arm team', body) + self.assertNotIn('Changes included', body) + + def test_update_announcement_lists_changes(self): + self.branch('scarthgap') + self.git('checkout', '-q', 'scarthgap') + self.git('tag', '-a', '5.0', '-m', 'initial') + self.git('tag', '-a', 'yocto-5.0', '-m', 'initial') + self.commit('fix one') + self.git('tag', '-a', '5.0.1', '-m', 'update') + self.git('tag', '-a', 'yocto-5.0.1', '-m', 'update') + self.refresh('scarthgap') + manager = release.ReleaseManager() + subject, body = manager.announcement('scarthgap', '5.0.1') + self.assertIn('fix one', body) + self.assertIn('1 commits', body) + self.assertIn('5.0.1 and yocto-5.0.1 tags', body) + + def test_push_dry_run_does_not_modify_remote(self): + self.branch('scarthgap') + self.git('tag', '-a', '5.0', '-m', 'initial') + self.git('tag', '-a', 'yocto-5.0', '-m', 'initial') + manager = release.ReleaseManager(execute=False) + with patch('builtins.print') as printer: + manager.push(['scarthgap']) + self.assertTrue(any('New tags: 2' in str(call) for call in printer.call_args_list)) + + + +class MailerTests(unittest.TestCase): + def test_auto_prefers_sendmail(self): + with patch.object(release.shutil, 'which', side_effect=lambda x: {'sendmail': '/usr/bin/sendmail', 'mutt': '/usr/bin/mutt'}.get(x)): + self.assertEqual(release.ReleaseManager(mailer='auto').mail_backend(), '/usr/bin/sendmail') + + def test_force_mutt(self): + with patch.object(release.shutil, 'which', side_effect=lambda x: {'sendmail': '/usr/bin/sendmail', 'mutt': '/usr/bin/mutt'}.get(x)): + self.assertEqual(release.ReleaseManager(mailer='mutt').mail_backend(), '/usr/bin/mutt') + + def test_force_sendmail_unavailable_does_not_fallback(self): + with patch.object(release.shutil, 'which', side_effect=lambda x: {'mutt': '/usr/bin/mutt'}.get(x)), patch.object(release.os, 'access', return_value=False): + manager = release.ReleaseManager(mailer='sendmail') + self.assertIsNone(manager.mail_backend()) + with self.assertRaisesRegex(release.ReleaseError, 'sendmail'): + manager.send('subject', 'body') + + def test_cli_override_env(self): + with patch.dict(os.environ, {'META_ARM_MAILER': 'mutt'}): + self.assertEqual(release.parser().parse_args(['--mailer', 'sendmail', 'check']).mailer, 'sendmail') + self.assertEqual(release.parser().parse_args(['check']).mailer, 'mutt') + + def test_invalid_mailer_rejected(self): + with self.assertRaises(release.ReleaseError): + release.ReleaseManager(mailer='invalid') + +if __name__ == '__main__': + unittest.main(verbosity=2) + + +class EditorTests(unittest.TestCase): + def test_editor_inherits_terminal_streams(self): + manager = release.ReleaseManager(edit=True) + with patch.object(manager, 'editor_command', return_value='vim -c "set spell"'): + with patch.object(release.subprocess, 'run') as invoke: + result = manager.edit_message('Subject', 'Body') + self.assertIsNone(result) + invoke.assert_called_once() + args, kwargs = invoke.call_args + self.assertEqual(args[0][:3], ['sh', '-c', 'vim -c "set spell" "$1"']) + self.assertTrue(kwargs['check']) + self.assertNotIn('stdout', kwargs) + self.assertNotIn('stderr', kwargs) + self.assertNotIn('stdin', kwargs) + + def test_editor_failure_prevents_send(self): + manager = release.ReleaseManager(edit=True) + with patch.object(manager, 'editor_command', return_value='vim'): + with patch.object(release.subprocess, 'run', side_effect=subprocess.CalledProcessError(1, 'vim')): + with self.assertRaisesRegex(release.ReleaseError, 'Editor exited unsuccessfully'): + manager.edit_message('Subject', 'Body') + + +class PlatformReleaseTests(GitFixture): + def test_platform_tag_name_and_date(self): + self.assertEqual(release.platform_tag('corstone1000', '2026.10.01'), + 'CORSTONE1000-2026.10.01') + with self.assertRaises(release.ReleaseError): + release.platform_tag('corstone1000', '2026.02.30') + + def test_platform_dry_run_does_not_create_tag(self): + manager = release.ReleaseManager() + tag = manager.platform('corstone1000', 'HEAD', '2026.10.01') + self.assertEqual(tag, 'CORSTONE1000-2026.10.01') + self.assertIsNone(manager.tag_object(tag)) + + def test_platform_existing_tag_is_idempotent(self): + self.git('tag', '-a', 'CORSTONE1000-2026.10.01', '-m', 'release') + manager = release.ReleaseManager(execute=True) + self.assertEqual(manager.platform('corstone1000', 'HEAD', '2026.10.01'), + 'CORSTONE1000-2026.10.01') + + def test_push_explicit_platform_tag(self): + self.git('tag', '-a', 'CORSTONE1000-2026.10.01', '-m', 'release') + manager = release.ReleaseManager(execute=True) + manager.push(('CORSTONE1000-2026.10.01',)) + remote = self.git('ls-remote', '--tags', '--refs', 'yoctoproject', + 'refs/tags/CORSTONE1000-2026.10.01') + self.assertIn('refs/tags/CORSTONE1000-2026.10.01', remote) + + def test_push_auto_discovers_platform_tag(self): + self.git('tag', '-a', 'CORSTONE1000-2026.10.01', '-m', 'release') + manager = release.ReleaseManager(execute=True) + manager.push() + remote = self.git('ls-remote', '--tags', '--refs', 'yoctoproject', + 'refs/tags/CORSTONE1000-2026.10.01') + self.assertIn('refs/tags/CORSTONE1000-2026.10.01', remote)