#!/usr/bin/env python3
# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""
Release automation script for maintainers.
This module provides a Click-based command line interface that prepares a new
MAFw release by bumping the version, updating NOTICE.txt, regenerating the
changelog, optionally generating release notes, committing tracked release
artifacts, creating the git tag, and optionally pushing to the remote
repository.
:author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
"""
from __future__ import annotations
import re
import shlex
import subprocess
from pathlib import Path
from typing import Any, Final, Literal
import click
VALID_HATCH_SEGMENTS: Final[tuple[str, ...]] = ('major', 'minor', 'micro', 'rc', 'alpha', 'beta', 'release')
"""Allowed Hatch version segments supported by this script."""
VERSION_PATTERN = re.compile(
r'^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<micro>\d+)(?:(?P<suffix>rc|a|b)(?P<suffix_num>\d+))?$'
)
"""Regular expression used to parse local version strings (stable/alpha/beta/rc)."""
STABLE_TAG_PATTERN = re.compile(r'^v(?P<major>\d+)\.(?P<minor>\d+)\.(?P<micro>\d+)$')
"""Regular expression used to identify stable git tags in the form ``vX.Y.Z``."""
ABOUT_FILE = Path('src/mafw/__about__.py')
"""Path to the version source file managed by ``hatch version``."""
CHANGELOG_FILE = Path('CHANGELOG.md')
"""Path to the changelog file that is regenerated for each release."""
NOTICE_FILE = Path('NOTICE.txt')
"""Path to the notice file containing the public project version."""
RELEASE_TEMPLATE_FILE = Path('.gitlab/release_templates/Default.md')
"""Path to the markdown template used to build release notes."""
RELEASE_SECTION_HEADERS = {
'new_features': '## 🚀 New Features',
'bug_fixes': '## 🐛 Bug Fixes',
'refactorings': '## ♻️ Refactorings',
'removed': '## 🗑️ Removals',
'deprecated': '## ⚠️ Deprecated',
'security': '## 🔒 Security',
'other_changes': '## ️*️⃣ Other Changes',
}
"""Release note section headers used in the markdown template."""
CONTEXT_SETTINGS = {'help_option_names': ['-h', '--help']}
"""Click context settings for command line help aliases."""
# The update-notice hook rewrites this exact section; keep pattern aligned.
NOTICE_VERSION_PATTERN = re.compile(
r"""MAFw - Modular Analysis Framework\n\nversion:\s*V[0-9]+\.[0-9]+\.[0-9]+(?:[-a-zA-Z0-9\.\-_]+)?""",
re.MULTILINE,
)
"""Pattern used to update the version block in ``NOTICE.txt``."""
[docs]
def _to_command_line(command: str | list[str]) -> str:
"""
Convert a command into a printable shell-like string.
:param command: Command expressed as a string or tokenized list.
:type command: str | list[str]
:return: Human-readable command line.
:rtype: str
"""
if isinstance(command, str):
return command
return shlex.join(command)
[docs]
def cmd(command: str | list[str], *, dry_run: bool = False, **kwargs: Any) -> subprocess.CompletedProcess[Any]:
"""
Execute a subprocess command and always return a completed process object.
The command line is always printed. In dry-run mode, execution is skipped
and a successful synthetic ``CompletedProcess`` is returned.
:param command: Command to execute.
:type command: str | list[str]
:param dry_run: If ``True``, print the command without executing it.
:type dry_run: bool
:param kwargs: Additional keyword arguments forwarded to ``subprocess.run``.
:type kwargs: Any
:return: Completed process produced by the command execution.
:rtype: subprocess.CompletedProcess[Any]
"""
click.echo(f'> {_to_command_line(command)}')
if dry_run:
return subprocess.CompletedProcess(args=command, returncode=0, stdout='', stderr='')
kwargs.setdefault('check', True)
kwargs.setdefault('text', True)
if isinstance(command, str):
kwargs.setdefault('shell', True)
return subprocess.run(command, **kwargs)
[docs]
def run_stdout(command: str | list[str], *, dry_run: bool = False, **kwargs: Any) -> str:
"""
Execute a command and return stripped standard output.
:param command: Command to execute.
:type command: str | list[str]
:param dry_run: If ``True``, do not execute the command.
:type dry_run: bool
:param kwargs: Additional keyword arguments forwarded to ``cmd``.
:type kwargs: Any
:return: Standard output stripped of leading and trailing whitespace.
:rtype: str
"""
result = cmd(command, dry_run=dry_run, capture_output=True, **kwargs)
stdout = result.stdout
if isinstance(stdout, bytes):
return stdout.decode().strip()
return (stdout or '').strip()
[docs]
def parse_version(version: str) -> tuple[int, int, int, int | None]:
"""
Parse a version string in the form ``X.Y.Z`` or with a pre-release suffix.
Supported suffixes follow Hatch/PEP 440 conventions:
- Release candidates: ``X.Y.ZrcN``
- Alpha releases: ``X.Y.ZaN``
- Beta releases: ``X.Y.ZbN``
:param version: Version string to parse.
:type version: str
:return: Parsed major, minor, micro, and optional pre-release index.
:rtype: tuple[int, int, int, int | None]
:raises click.ClickException: If the version format is unsupported.
"""
match = VERSION_PATTERN.fullmatch(version.strip())
if match is None:
raise click.ClickException(
f'Unsupported version format "{version}". Expected X.Y.Z, X.Y.ZrcN, X.Y.ZaN or X.Y.ZbN.'
)
suffix_group = match.group('suffix_num')
return (
int(match.group('major')),
int(match.group('minor')),
int(match.group('micro')),
int(suffix_group) if suffix_group is not None else None,
)
[docs]
def read_current_version() -> str:
"""
Read the project version using ``hatch version``.
:return: Current project version string.
:rtype: str
:raises click.ClickException: If the version cannot be extracted.
"""
version = run_stdout(['hatch', 'version'])
if not version:
raise click.ClickException('Unable to determine the current version from hatch.')
return version
VersionKind = Literal['stable', 'rc', 'alpha', 'beta']
"""Supported release kinds used to drive changelog and release-note behavior."""
[docs]
def normalize_hatch_segments(segments: str) -> str:
"""
Normalize the user-provided segment selector to a Hatch-compatible token list.
The selector supports comma-separated segments and follows Hatch semantics.
Examples:
- ``minor,rc``: bump minor and create/reset an RC suffix.
- ``rc``: increment the release-candidate counter only.
- ``alpha`` / ``beta``: create alpha/beta pre-release suffix.
- ``release``: remove any pre-release suffix (stable release).
:param segments: Raw selector as passed on the command line.
:type segments: str
:return: Normalized Hatch selector (comma-separated, lowercase, no whitespace).
:rtype: str
:raises click.ClickException: If the selector is empty or contains unsupported segments.
"""
normalized = [token.strip().lower() for token in segments.split(',') if token.strip()]
if not normalized:
raise click.ClickException('Missing version segment selector. Example: "minor,rc" or "rc".')
invalid = sorted({token for token in normalized if token not in VALID_HATCH_SEGMENTS})
if invalid:
raise click.ClickException(
f'Invalid version segment(s): {", ".join(invalid)}. '
f'Use one or more of: {", ".join(VALID_HATCH_SEGMENTS)} (comma-separated).'
)
# assure that there are not duplicated segments in the token list
if len(set(normalized)) != len(normalized):
raise click.ClickException('Duplicate version segments are not allowed.')
# assure that there is only one segment among major, minor and micro
core_segments = {'major', 'minor', 'micro'}
if sum(token in core_segments for token in normalized) > 1:
raise click.ClickException('Use at most one of: major, minor, micro.')
# assure that there is only one pre-release segment
prerelease_segments = {'rc', 'alpha', 'beta'}
if sum(token in prerelease_segments for token in normalized) > 1:
raise click.ClickException('Use at most one of: rc, alpha, beta.')
return ','.join(normalized)
[docs]
def classify_version(version: str) -> VersionKind:
"""
Classify a version string into stable/rc/alpha/beta.
:param version: Version string to classify.
:type version: str
:return: Classified version kind.
:rtype: VersionKind
:raises click.ClickException: If the version cannot be parsed.
"""
match = VERSION_PATTERN.fullmatch(version.strip())
if match is None:
raise click.ClickException(f'Unsupported version format "{version}".')
suffix = match.group('suffix')
if suffix is None:
return 'stable'
if suffix == 'rc':
return 'rc'
if suffix == 'a':
return 'alpha'
if suffix == 'b':
return 'beta'
raise click.ClickException(f'Unsupported pre-release suffix "{suffix}" in version "{version}".')
[docs]
def check_main_branch() -> None:
"""
Ensure the release process is executed from the ``main`` branch.
:raises click.ClickException: If the current branch is not ``main``.
"""
branch = run_stdout('git rev-parse --abbrev-ref HEAD')
if branch != 'main':
raise click.ClickException('Must be on main branch.')
[docs]
def ensure_clean_git() -> None:
"""
Ensure the git working tree is clean (excluding untracked files).
:raises click.ClickException: If tracked changes are present.
"""
status = run_stdout('git status --porcelain -uno')
if status:
raise click.ClickException('Git working tree is not clean. Commit or stash changes first.')
[docs]
def get_last_stable_tag() -> str | None:
"""
Return the latest stable tag matching ``vX.Y.Z``.
:return: Latest stable tag or ``None`` when no stable tags are present.
:rtype: str | None
"""
tags = run_stdout('git tag').splitlines()
stable_tags: list[tuple[tuple[int, int, int], str]] = []
for tag in tags:
match = STABLE_TAG_PATTERN.fullmatch(tag.strip())
if match is None:
continue
version_tuple = (
int(match.group('major')),
int(match.group('minor')),
int(match.group('micro')),
)
stable_tags.append((version_tuple, tag.strip()))
if not stable_tags:
return None
stable_tags.sort(key=lambda item: item[0])
return stable_tags[-1][1]
[docs]
def parse_stable_tag(tag: str) -> tuple[int, int, int]:
"""
Parse a stable tag in the form ``vX.Y.Z``.
:param tag: Stable tag value.
:type tag: str
:return: Parsed stable version tuple.
:rtype: tuple[int, int, int]
:raises click.ClickException: If the tag does not match ``vX.Y.Z``.
"""
match = STABLE_TAG_PATTERN.fullmatch(tag)
if match is None:
raise click.ClickException(f'Unsupported stable tag format "{tag}".')
return (
int(match.group('major')),
int(match.group('minor')),
int(match.group('micro')),
)
[docs]
def check_missing_release(current_version: str) -> None:
"""
Prevent creating a new release when a stable release is already missing a tag.
If the current project version is stable and ahead of the last stable git tag,
the release is aborted to avoid skipping the untagged stable release.
:param current_version: Current project version.
:type current_version: str
:raises click.ClickException: If a missing stable release is detected.
"""
current_kind = classify_version(current_version)
if current_kind != 'stable':
return
current_major, current_minor, current_micro, _ = parse_version(current_version)
last_stable_tag = get_last_stable_tag()
if last_stable_tag is None:
return
last_major, last_minor, last_micro = parse_stable_tag(last_stable_tag)
current_tuple = (current_major, current_minor, current_micro)
last_tuple = (last_major, last_minor, last_micro)
if current_tuple > last_tuple:
raise click.ClickException(
'Missing stable release detected: '
f'last stable tag is {last_stable_tag}, but current version is v{current_version}. '
'Tag the current stable version first, or disable this check with '
'--without-missing-release-check.'
)
[docs]
def prevent_duplicate_tag(version: str) -> None:
"""
Ensure the target release tag does not already exist.
:param version: Target release version.
:type version: str
:raises click.ClickException: If ``v<version>`` already exists.
"""
existing_tags = run_stdout('git tag').splitlines()
if f'v{version}' in existing_tags:
raise click.ClickException(f'Tag v{version} already exists.')
[docs]
def bump_version(segment: str, *, dry_run: bool) -> str:
"""
Compute or execute the version bump depending on dry-run mode.
Hatch is used as single source of truth for the resolved target version.
In dry-run mode, the ``__about__.py`` file is temporarily rewritten by
Hatch and restored afterwards so the git worktree remains unchanged.
:param segment: Hatch selector segments (comma-separated).
:type segment: str
:param dry_run: Whether command execution is disabled.
:type dry_run: bool
:return: New version string.
:rtype: str
"""
hatch_selector = normalize_hatch_segments(segment)
original_about = ABOUT_FILE.read_text(encoding='utf-8')
try:
cmd(['hatch', 'version', hatch_selector])
version = run_stdout(['hatch', 'version'])
if not version:
raise click.ClickException('Unable to determine the new version from hatch.')
finally:
if dry_run:
ABOUT_FILE.write_text(original_about, encoding='utf-8')
if dry_run:
click.echo(f'Computed version (dry-run): {version}')
else:
click.echo(f'New version: {version}')
return version
[docs]
def update_notice_version(version: str, dry_run: bool) -> None:
"""
Update the version in ``NOTICE.txt`` to match the release version.
:param version: Target release version.
:type version: str
:param dry_run: Whether filesystem changes are disabled.
:type dry_run: bool
:raises click.ClickException: If NOTICE.txt is missing or has unexpected format.
"""
if not NOTICE_FILE.exists():
raise click.ClickException(f'Unable to find {NOTICE_FILE}.')
replacement = f'MAFw - Modular Analysis Framework\\n\\nversion: V{version}'
content = NOTICE_FILE.read_text(encoding='utf-8')
updated, replacements = NOTICE_VERSION_PATTERN.subn(replacement, content)
if replacements != 1:
raise click.ClickException(
f'Unable to update version in {NOTICE_FILE}: expected one matching version block, found {replacements}.'
)
if dry_run:
click.echo(f'NOTICE update planned for version V{version}.')
return
NOTICE_FILE.write_text(updated, encoding='utf-8')
click.echo(f'Updated {NOTICE_FILE} to version V{version}.')
[docs]
def generate_changelog(version: str, dry_run: bool) -> None:
"""
Generate the project changelog for the target release.
:param version: Target release version.
:type version: str
:param dry_run: Whether command execution is disabled.
:type dry_run: bool
"""
click.echo('Generating changelog...')
cmd(['hatch', 'run', 'dev.py3.14:change', '-t', version, '--output', str(CHANGELOG_FILE)], dry_run=dry_run)
[docs]
def get_release_note_base_ref() -> str:
"""
Determine the git reference used as baseline for release-note metadata.
:return: Last stable tag if available, otherwise the first commit hash.
:rtype: str
"""
stable_tag = get_last_stable_tag()
if stable_tag is not None:
return stable_tag
first_commit = run_stdout('git rev-list --max-parents=0 HEAD').splitlines()
if not first_commit:
raise click.ClickException('Unable to determine release-note baseline reference.')
return first_commit[0]
[docs]
def get_release_statistics(since_ref: str) -> tuple[str, str]:
"""
Collect release statistics since the baseline reference.
:param since_ref: Baseline reference to compare against ``HEAD``.
:type since_ref: str
:return: Commit count and short diff statistics.
:rtype: tuple[str, str]
"""
commit_count = run_stdout(f'git rev-list --count {since_ref}..HEAD')
files_changed = run_stdout(f'git diff --shortstat {since_ref}..HEAD')
return commit_count, files_changed
[docs]
def get_contributors(since_ref: str) -> list[str]:
"""
Collect contributor names since the baseline reference.
:param since_ref: Baseline reference to compare against ``HEAD``.
:type since_ref: str
:return: Ordered list of contributor names.
:rtype: list[str]
"""
output = run_stdout(f'git shortlog -sn {since_ref}..HEAD')
contributors: list[str] = []
for line in output.splitlines():
match = re.match(r'^\s*\d+\s+(.+)$', line)
if match is not None:
contributors.append(match.group(1).strip())
return contributors
[docs]
def _classify_changelog_subsection(subsection_heading: str) -> str | None:
"""
Map a changelog subsection heading to a release-note category key.
:param subsection_heading: Raw level-3 heading from changelog.
:type subsection_heading: str
:return: Category key, or ``None`` if the heading is not mappable.
:rtype: str | None
"""
normalized = re.sub(r'[^a-z]+', ' ', subsection_heading.lower()).strip()
if 'new' in normalized and 'feature' in normalized:
return 'new_features'
if 'bug' in normalized and 'fix' in normalized:
return 'bug_fixes'
if 'refactor' in normalized:
return 'refactorings'
if 'doc' in normalized:
return 'documentation'
if 'other' in normalized:
return 'other_changes'
return None
[docs]
def render_release_note_section(content: str, header: str, section_markdown: str) -> str:
"""
Replace a release-note section in the markdown template.
If the provided section markdown is empty, the entire section (header and
placeholder) is removed from the content.
:param content: Current release note markdown text.
:type content: str
:param header: Section header to replace.
:type header: str
:param section_markdown: Markdown content to insert under the section header.
:type section_markdown: str
:return: Updated markdown text.
:rtype: str
:raises click.ClickException: If the section header cannot be matched in template.
"""
section_content = section_markdown.strip()
if not section_content:
# Remove header and placeholder comment, including trailing whitespace
# to avoid leaving multiple empty lines between sections.
pattern = re.escape(header) + r'\s*\n\s*<!--.*?-->\s*'
updated, replacements = re.subn(pattern, '', content, flags=re.DOTALL)
else:
pattern = re.escape(header) + r'\s*\n\s*<!--.*?-->'
updated, replacements = re.subn(pattern, f'{header}\n{section_content}', content, flags=re.DOTALL)
if replacements != 1:
raise click.ClickException(f'Unable to update section "{header}" in {RELEASE_TEMPLATE_FILE}.')
return updated
[docs]
def create_release_note(version: str, dry_run: bool) -> Path:
"""
Create the release note markdown file for the given version.
Change sections are copied from the generated changelog for the same
version to ensure release notes stay aligned with changelog content.
:param version: Target release version.
:type version: str
:param dry_run: Whether command execution is disabled.
:type dry_run: bool
:return: Path to the release note markdown file.
:rtype: Path
:raises click.ClickException: If the template is missing.
"""
output_path = Path(f'release_note_v{version}.md')
if dry_run:
click.echo(f'Release note would be generated at: {output_path}')
return output_path
if not RELEASE_TEMPLATE_FILE.exists():
raise click.ClickException(f'Release note template not found: {RELEASE_TEMPLATE_FILE}')
change_sections = extract_change_sections_from_changelog(version)
since_ref = get_release_note_base_ref()
commit_count, files_changed = get_release_statistics(since_ref)
contributors = get_contributors(since_ref)
content = RELEASE_TEMPLATE_FILE.read_text(encoding='utf-8')
content = content.replace('${TAG}', f'v{version}')
content = render_release_note_section(
content, RELEASE_SECTION_HEADERS['new_features'], change_sections['new_features']
)
content = render_release_note_section(content, RELEASE_SECTION_HEADERS['bug_fixes'], change_sections['bug_fixes'])
content = render_release_note_section(
content, RELEASE_SECTION_HEADERS['refactorings'], change_sections['refactorings']
)
content = render_release_note_section(content, RELEASE_SECTION_HEADERS['removed'], change_sections['removed'])
content = render_release_note_section(content, RELEASE_SECTION_HEADERS['deprecated'], change_sections['deprecated'])
content = render_release_note_section(content, RELEASE_SECTION_HEADERS['security'], change_sections['security'])
content = render_release_note_section(
content, RELEASE_SECTION_HEADERS['other_changes'], change_sections['other_changes']
)
content = render_release_note_section(
content, '## 👥 Contributors', '\n'.join(f'* {name}' for name in contributors)
)
content = render_release_note_section(
content,
'## 📊 Statistics',
f'* Commits: {commit_count}\n* Files changed: {files_changed}',
)
output_path.write_text(content, encoding='utf-8')
click.echo(f'Release note generated: {output_path}')
return output_path
[docs]
def commit_changes(version: str, dry_run: bool, *, include_changelog: bool = True) -> None:
"""
Commit tracked release artifacts for the target version.
The generated release note is intentionally not staged and remains
untracked as requested.
:param version: Target release version.
:type version: str
:param dry_run: Whether command execution is disabled.
:type dry_run: bool
:param include_changelog: Whether ``CHANGELOG.md`` should be staged and
committed as part of the release artifacts.
:type include_changelog: bool
"""
click.echo('Committing tracked release artifacts...')
tracked_files: list[str] = [str(ABOUT_FILE), str(NOTICE_FILE)]
if include_changelog:
tracked_files.append(str(CHANGELOG_FILE))
cmd(['git', 'add', *tracked_files], dry_run=dry_run)
cmd(['git', 'commit', '-m', f'chore(release): v{version}'], dry_run=dry_run)
[docs]
def create_tag(version: str, dry_run: bool) -> str:
"""
Create the local git tag for the target version.
:param version: Target release version.
:type version: str
:param dry_run: Whether command execution is disabled.
:type dry_run: bool
:return: Created tag name.
:rtype: str
"""
tag = f'v{version}'
click.echo(f'Creating local tag {tag}...')
cmd(['git', 'tag', tag], dry_run=dry_run)
return tag
[docs]
def push_changes(dry_run: bool) -> None:
"""
Push commits and tags to the remote repository.
:param dry_run: Whether command execution is disabled.
:type dry_run: bool
"""
click.echo('Pushing commits and tags...')
cmd(['git', 'push', 'origin-ssh', 'main'], dry_run=dry_run)
cmd(['git', 'push', '--tags'], dry_run=dry_run)
@click.command(
context_settings=CONTEXT_SETTINGS,
help=(
'Prepare a new MAFw release from the main branch.\n\n'
'The command bumps the project version via hatch, updates NOTICE.txt, '
'regenerates CHANGELOG.md, optionally generates a release note markdown file, '
'commits tracked release files, creates a local git tag, and optionally pushes '
'commits and tags to code.europa.eu/main using ssh.\n\n'
'Release-note change sections are copied from the generated changelog section '
'for the target version to keep both artifacts aligned.\n\n'
'Safety checks include clean working tree, duplicate tag prevention, and optional '
'missing-release detection that blocks a new release when the current stable version '
'is ahead of the latest stable tag.\n\n'
'The version selector supports comma-separated Hatch segments (e.g. "minor,rc", "rc", '
'"alpha", "beta", "release").\n\n'
'Dry-run mode prints all commands and resolves the target version via hatch, then restores '
f'{ABOUT_FILE} so the git worktree remains unchanged.'
),
)
@click.argument('segments', type=str)
@click.option(
'--dry-run/--no-dry-run',
default=False,
show_default=True,
help='Print commands and computed actions without executing git/hatch operations.',
)
@click.option(
'--with-push/--without-push',
default=True,
show_default=True,
help='Enable or skip pushing commits and tags to the remote repository.',
)
@click.option(
'--with-release-note/--without-release-note',
default=True,
show_default=True,
help='Enable or skip generation of release_note_v<version>.md.',
)
@click.option(
'--with-missing-release-check/--without-missing-release-check',
default=True,
show_default=True,
help='Enable or skip the guard that prevents skipping an untagged stable release.',
)
def main(
segments: str,
dry_run: bool,
with_push: bool,
with_release_note: bool,
with_missing_release_check: bool,
) -> None:
"""
Execute the release pipeline for the selected version segment.
:param segments: Hatch selector segments (comma-separated).
:type segments: str
:param dry_run: Whether command execution is disabled.
:type dry_run: bool
:param with_push: Whether remote pushes are enabled.
:type with_push: bool
:param with_release_note: Whether the release note file should be generated.
:type with_release_note: bool
:param with_missing_release_check: Whether missing-release safety check is enabled.
:type with_missing_release_check: bool
"""
current_version = read_current_version()
if dry_run:
click.echo(
'Dry-run mode enabled: commands are printed; git/changelog writes are skipped; '
f'version is resolved via hatch and {ABOUT_FILE} is restored afterwards.'
)
else:
check_main_branch()
ensure_clean_git()
if with_missing_release_check:
check_missing_release(current_version)
version = bump_version(
dry_run=dry_run,
segment=segments,
)
if not dry_run:
prevent_duplicate_tag(version)
update_notice_version(version, dry_run=dry_run)
generate_changelog(version, dry_run=dry_run)
changelog_updated = True
release_note_path: Path | None = None
if with_release_note:
release_note_path = create_release_note(version, dry_run=dry_run)
click.echo(f'Release note file: {release_note_path}')
commit_changes(version, dry_run=dry_run, include_changelog=True)
tag = create_tag(version, dry_run=dry_run)
if with_push:
push_changes(dry_run=dry_run)
else:
click.echo('Skipping remote push because --without-push is set.')
click.echo('')
click.echo('Release pipeline completed.')
click.echo(f'Version: v{version}')
click.echo(f'Tag: {tag}')
if with_release_note and release_note_path is not None:
click.echo(f'Release note: {release_note_path} (left untracked)')
if changelog_updated:
click.echo(f'{CHANGELOG_FILE} updated.')
click.echo(f'{NOTICE_FILE} updated.')
if __name__ == '__main__':
main()