Source code for semantic_release.cli.commands.version

from __future__ import annotations

import logging
import os
import subprocess
import sys
from collections import defaultdict
from datetime import datetime
from typing import TYPE_CHECKING

import click
import shellingham  # type: ignore[import]
from click_option_group import MutuallyExclusiveOptionGroup, optgroup
from git import Repo
from requests import HTTPError

from semantic_release.changelog import ReleaseHistory
from semantic_release.cli.changelog_writer import (
    generate_release_notes,
    write_changelog_files,
)
from semantic_release.cli.github_actions_output import VersionGitHubActionsOutput
from semantic_release.cli.util import noop_report, rprint
from semantic_release.const import DEFAULT_SHELL, DEFAULT_VERSION
from semantic_release.enums import LevelBump
from semantic_release.errors import (
    BuildDistributionsError,
    GitCommitEmptyIndexError,
    UnexpectedResponse,
)
from semantic_release.gitproject import GitProject
from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase
from semantic_release.version import (
    Version,
    VersionTranslator,
    next_version,
    tags_and_versions,
)

if TYPE_CHECKING:  # pragma: no cover
    from pathlib import Path
    from typing import Iterable, Mapping

    from git.refs.tag import Tag

    from semantic_release.cli.cli_context import CliContextObj
    from semantic_release.version.declaration import VersionDeclarationABC


log = logging.getLogger(__name__)


[docs] def is_forced_prerelease( as_prerelease: bool, forced_level_bump: LevelBump | None, prerelease: bool ) -> bool: """ Determine if this release is forced to have prerelease on/off. If ``force_prerelease`` is set then yes. Otherwise if we are forcing a specific level bump without force_prerelease, it's False. Otherwise (``force_level is None``) use the value of ``prerelease`` """ local_vars = list(locals().items()) log.debug( "%s: %s", is_forced_prerelease.__name__, ", ".join(f"{k} = {v}" for k, v in local_vars), ) return ( as_prerelease or forced_level_bump is LevelBump.PRERELEASE_REVISION or ((forced_level_bump is None) and prerelease) )
[docs] def last_released(repo_dir: Path, tag_format: str) -> tuple[Tag, Version] | None: with Repo(str(repo_dir)) as git_repo: ts_and_vs = tags_and_versions( git_repo.tags, VersionTranslator(tag_format=tag_format) ) return ts_and_vs[0] if ts_and_vs else None
[docs] def version_from_forced_level( repo_dir: Path, forced_level_bump: LevelBump, translator: VersionTranslator ) -> Version: with Repo(str(repo_dir)) as git_repo: ts_and_vs = tags_and_versions(git_repo.tags, translator) # If we have no tags, return the default version if not ts_and_vs: return Version.parse(DEFAULT_VERSION).bump(forced_level_bump) _, latest_version = ts_and_vs[0] if forced_level_bump is not LevelBump.PRERELEASE_REVISION: return latest_version.bump(forced_level_bump) # We need to find the latest version with the prerelease token # we're looking for, and return that version + an increment to # the prerelease revision. # NOTE this can probably be cleaned up. # ts_and_vs are in order, so check if we're looking at prereleases # for the same (major, minor, patch) as the latest version. # If we are, we can increment the revision and we're done. If # we don't find a prerelease targeting this version with the same # token as the one we're looking to prerelease, we can use revision 1. for _, version in ts_and_vs: if not ( version.major == latest_version.major and version.minor == latest_version.minor and version.patch == latest_version.patch ): break if ( version.is_prerelease and version.prerelease_token == translator.prerelease_token ): return version.bump(LevelBump.PRERELEASE_REVISION) return latest_version.to_prerelease(token=translator.prerelease_token, revision=1)
[docs] def apply_version_to_source_files( repo_dir: Path, version_declarations: Iterable[VersionDeclarationABC], version: Version, noop: bool = False, ) -> list[str]: paths = [ str(declaration.path.resolve().relative_to(repo_dir)) for declaration in version_declarations ] if noop: noop_report( "would have updated versions in the following paths:" + "".join(f"\n {path}" for path in paths) ) return paths log.debug("writing version %s to source paths %s", version, paths) for declaration in version_declarations: new_content = declaration.replace(new_version=version) declaration.path.write_text(new_content) return paths
[docs] def shell( cmd: str, *, env: Mapping[str, str] | None = None, check: bool = True ) -> subprocess.CompletedProcess: shell: str | None try: shell, _ = shellingham.detect_shell() except shellingham.ShellDetectionFailure: log.warning("failed to detect shell, using default shell: %s", DEFAULT_SHELL) log.debug("stack trace", exc_info=True) shell = DEFAULT_SHELL if not shell: raise TypeError("'shell' is None") shell_cmd_param = defaultdict( lambda: "-c", { "cmd": "/c", "powershell": "-Command", "pwsh": "-Command", }, ) return subprocess.run( # noqa: S603 [shell, shell_cmd_param[shell], cmd], env=(env or {}), check=check, )
[docs] def is_windows() -> bool: return sys.platform == "win32"
[docs] def get_windows_env() -> Mapping[str, str | None]: return { environment_variable: os.getenv(environment_variable, None) for environment_variable in ( "ALLUSERSAPPDATA", "ALLUSERSPROFILE", "APPDATA", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)", "DEFAULTUSERPROFILE", "HOMEPATH", "PATHEXT", "PROFILESFOLDER", "PROGRAMFILES", "PROGRAMFILES(X86)", "SYSTEM", "SYSTEM16", "SYSTEM32", "SYSTEMDRIVE", "SYSTEMPROFILE", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "USERSID", "WINDIR", ) }
[docs] def build_distributions( build_command: str | None, build_command_env: Mapping[str, str] | None = None, noop: bool = False, ) -> None: """ Run the build command to build the distributions. :param build_command: The build command to run. :param build_command_env: The environment variables to use when running the build command. :param noop: Whether or not to run the build command. :raises: BuildDistributionsError: if the build command fails """ if not build_command: rprint("[green]No build command specified, skipping") return if noop: noop_report(f"would have run the build_command {build_command}") return log.info("Running build command %s", build_command) rprint(f"[bold green]:hammer_and_wrench: Running build command: {build_command}") build_env_vars: dict[str, str] = dict( filter( lambda k_v: k_v[1] is not None, # type: ignore[arg-type] { # Common values "PATH": os.getenv("PATH", ""), "HOME": os.getenv("HOME", None), "VIRTUAL_ENV": os.getenv("VIRTUAL_ENV", None), # Windows environment variables **(get_windows_env() if is_windows() else {}), # affects build decisions "CI": os.getenv("CI", None), # Identifies which CI environment "GITHUB_ACTIONS": os.getenv("GITHUB_ACTIONS", None), "GITLAB_CI": os.getenv("GITLAB_CI", None), "GITEA_ACTIONS": os.getenv("GITEA_ACTIONS", None), "BITBUCKET_CI": ( str(True).lower() if os.getenv("BITBUCKET_REPO_FULL_NAME", None) else None ), "PSR_DOCKER_GITHUB_ACTION": os.getenv("PSR_DOCKER_GITHUB_ACTION", None), **(build_command_env or {}), }.items(), ) ) try: shell(build_command, env=build_env_vars, check=True) rprint("[bold green]Build completed successfully!") except subprocess.CalledProcessError as exc: log.exception(exc) log.error("Build command failed with exit code %s", exc.returncode) # noqa: TRY400 raise BuildDistributionsError from exc
@click.command( short_help="Detect and apply a new version", context_settings={ "help_option_names": ["-h", "--help"], }, ) @optgroup.group("Print flags", cls=MutuallyExclusiveOptionGroup) @optgroup.option( "--print", "print_only", is_flag=True, help="Print the next version and exit" ) @optgroup.option( "--print-tag", "print_only_tag", is_flag=True, help="Print the next version tag and exit", ) @optgroup.option( "--print-last-released", is_flag=True, help="Print the last released version and exit", ) @optgroup.option( "--print-last-released-tag", is_flag=True, help="Print the last released version tag and exit", ) @click.option( "--as-prerelease", "as_prerelease", is_flag=True, help="Ensure the next version to be released is a prerelease version", ) @click.option( "--prerelease-token", "prerelease_token", default=None, help="Force the next version to use this prerelease token, if it is a prerelease", ) @click.option( "--major", "force_level", flag_value="major", help="Force the next version to be a major release", ) @click.option( "--minor", "force_level", flag_value="minor", help="Force the next version to be a minor release", ) @click.option( "--patch", "force_level", flag_value="patch", help="Force the next version to be a patch release", ) @click.option( "--prerelease", "force_level", flag_value="prerelease_revision", help="Force the next version to be a prerelease", ) @click.option( "--commit/--no-commit", "commit_changes", default=True, help="Whether or not to commit changes locally", ) @click.option( "--tag/--no-tag", "create_tag", default=True, help="Whether or not to create a tag for the new version", ) @click.option( "--changelog/--no-changelog", "update_changelog", default=True, help="Whether or not to update the changelog", ) @click.option( "--push/--no-push", "push_changes", default=True, help="Whether or not to push the new commit and tag to the remote", ) @click.option( "--vcs-release/--no-vcs-release", "make_vcs_release", default=True, help="Whether or not to create a release in the remote VCS, if supported", ) @click.option( "--build-metadata", "build_metadata", default=os.getenv("PSR_BUILD_METADATA"), help="Build metadata to append to the new version", ) @click.option( "--skip-build", "skip_build", default=False, is_flag=True, help="Skip building the current project", ) @click.pass_obj def version( # noqa: C901 cli_ctx: CliContextObj, print_only: bool, print_only_tag: bool, print_last_released: bool, print_last_released_tag: bool, as_prerelease: bool, prerelease_token: str | None, commit_changes: bool, create_tag: bool, update_changelog: bool, push_changes: bool, make_vcs_release: bool, build_metadata: str | None, skip_build: bool, force_level: str | None = None, ) -> None: """ Detect the semantically correct next version that should be applied to your project. By default: * Write this new version to the project metadata locations specified in the configuration file * Create a new commit with these locations and any other assets configured to be included in a release * Tag this commit according the configured format, with a tag that uniquely identifies the version being released. * Push the new tag and commit to the remote for the repository * Create a release (if supported) in the remote VCS for this tag """ ctx = click.get_current_context() # Enable any cli overrides of configuration before asking for the runtime context config = cli_ctx.raw_config # We can short circuit updating the release if we are only printing the last released version if print_last_released or print_last_released_tag: # TODO: get tag format a better way if not ( last_release := last_released(config.repo_dir, tag_format=config.tag_format) ): log.warning("No release tags found.") return click.echo(last_release[0] if print_last_released_tag else last_release[1]) return # TODO: figure out --print of next version with & without branch validation # do you always need a prerelease token if its not --as-prerelease? runtime = cli_ctx.runtime_ctx translator = runtime.version_translator parser = runtime.commit_parser hvcs_client = runtime.hvcs_client assets = runtime.assets commit_author = runtime.commit_author commit_message = runtime.commit_message major_on_zero = runtime.major_on_zero no_verify = runtime.no_git_verify opts = runtime.global_cli_options gha_output = VersionGitHubActionsOutput(released=False) forced_level_bump = None if not force_level else LevelBump.from_string(force_level) prerelease = is_forced_prerelease( as_prerelease=as_prerelease, forced_level_bump=forced_level_bump, prerelease=runtime.prerelease, ) if prerelease_token: log.info("Forcing use of %s as the prerelease token", prerelease_token) translator.prerelease_token = prerelease_token # Only push if we're committing changes if push_changes and not commit_changes and not create_tag: log.info("changes will not be pushed because --no-commit disables pushing") push_changes &= commit_changes # Only push if we're creating a tag if push_changes and not create_tag and not commit_changes: log.info("new tag will not be pushed because --no-tag disables pushing") push_changes &= create_tag # Only make a release if we're pushing the changes if make_vcs_release and not push_changes: log.info("No vcs release will be created because pushing changes is disabled") make_vcs_release &= push_changes if not forced_level_bump: with Repo(str(runtime.repo_dir)) as git_repo: new_version = next_version( repo=git_repo, translator=translator, commit_parser=parser, prerelease=prerelease, major_on_zero=major_on_zero, allow_zero_version=runtime.allow_zero_version, ) else: log.warning( "Forcing a '%s' release due to '--%s' command-line flag", force_level, ( force_level if forced_level_bump is not LevelBump.PRERELEASE_REVISION else "prerelease" ), ) new_version = version_from_forced_level( repo_dir=runtime.repo_dir, forced_level_bump=forced_level_bump, translator=translator, ) # We only turn the forced version into a prerelease if the user has specified # that that is what they want on the command-line; otherwise we assume they are # forcing a full release new_version = ( new_version.to_prerelease(token=translator.prerelease_token) if prerelease else new_version.finalize_version() ) if build_metadata: new_version.build_metadata = build_metadata # Update GitHub Actions output value with new version & set delayed write gha_output.version = new_version ctx.call_on_close(gha_output.write_if_possible) # Make string variant of version && Translate to tag if necessary version_to_print = ( str(new_version) if not print_only_tag else translator.str_to_tag(str(new_version)) ) # Print the new version so that command-line output capture will work click.echo(version_to_print) with Repo(str(runtime.repo_dir)) as git_repo: # TODO: performance improvement - cache the result of tags_and_versions (previously done in next_version()) previously_released_versions = { v for _, v in tags_and_versions(git_repo.tags, translator) } # If the new version has already been released, we fail and abort if strict; # otherwise we exit with 0. if new_version in previously_released_versions: err_msg = str.join( " ", [ "[bold orange1]No release will be made,", f"{new_version!s} has already been released!", ], ) if opts.strict: click.echo(err_msg, err=True) ctx.exit(2) rprint(err_msg) return if print_only or print_only_tag: return with Repo(str(runtime.repo_dir)) as git_repo: release_history = ReleaseHistory.from_git_history( repo=git_repo, translator=translator, commit_parser=parser, exclude_commit_patterns=runtime.changelog_excluded_commit_patterns, ) rprint(f"[bold green]The next version is: [white]{new_version!s}[/white]! :rocket:") commit_date = datetime.now() try: # Create release object for the new version # This will be used to generate the changelog prior to the commit and/or tag release_history = release_history.release( new_version, tagger=commit_author, committer=commit_author, tagged_date=commit_date, ) except ValueError as ve: click.echo(str(ve), err=True) ctx.exit(1) all_paths_to_add: list[str] = [] if update_changelog: # Write changelog files & add them to the list of files to commit all_paths_to_add.extend( write_changelog_files( runtime_ctx=runtime, release_history=release_history, hvcs_client=hvcs_client, noop=opts.noop, ) ) # Apply the new version to the source files files_with_new_version_written = apply_version_to_source_files( repo_dir=runtime.repo_dir, version_declarations=runtime.version_declarations, version=new_version, noop=opts.noop, ) all_paths_to_add.extend(files_with_new_version_written) all_paths_to_add.extend(assets or []) # Build distributions before committing any changes - this way if the # build fails, modifications to the source code won't be committed if skip_build: rprint("[bold orange1]Skipping build due to --skip-build flag") else: try: build_distributions( build_command=runtime.build_command, build_command_env={ # User defined overrides of environment (from config) **runtime.build_command_env, # PSR injected environment variables "NEW_VERSION": str(new_version), }, noop=opts.noop, ) except BuildDistributionsError as exc: click.echo(str(exc), err=True) click.echo("Build failed, aborting release", err=True) ctx.exit(1) project = GitProject( directory=runtime.repo_dir, commit_author=runtime.commit_author, credential_masker=runtime.masker, ) # Preparing for committing changes if commit_changes: project.git_add(paths=all_paths_to_add, noop=opts.noop) # NOTE: If we haven't modified any source code then we skip trying to make a commit # and any tag that we apply will be to the HEAD commit (made outside of # running PSR try: project.git_commit( message=commit_message.format(version=new_version), date=int(commit_date.timestamp()), no_verify=no_verify, noop=opts.noop, ) except GitCommitEmptyIndexError: log.info("No local changes to add to any commit, skipping") # Tag the version after potentially creating a new HEAD commit. # This way if no source code is modified, i.e. all metadata updates # are disabled, and the changelog generation is disabled or it's not # modified, then the HEAD commit will be tagged as a release commit # despite not being made by PSR if commit_changes or create_tag: project.git_tag( tag_name=new_version.as_tag(), message=new_version.as_tag(), noop=opts.noop, ) if push_changes: remote_url = runtime.hvcs_client.remote_url( use_token=not runtime.ignore_token_for_push ) if commit_changes: # TODO: integrate into push branch with Repo(str(runtime.repo_dir)) as git_repo: active_branch = git_repo.active_branch.name project.git_push_branch( remote_url=remote_url, branch=active_branch, noop=opts.noop, ) if create_tag: # push specific tag refspec (that we made) to remote project.git_push_tag( remote_url=remote_url, tag=new_version.as_tag(), noop=opts.noop, ) # Update GitHub Actions output value now that release has occurred gha_output.released = True if not make_vcs_release: return if not isinstance(hvcs_client, RemoteHvcsBase): log.info("Remote does not support releases. Skipping release creation...") return release_notes = generate_release_notes( hvcs_client, release_history.released[new_version], runtime.template_dir, history=release_history, style=runtime.changelog_style, ) exception: Exception | None = None help_message = "" try: hvcs_client.create_release( tag=new_version.as_tag(), release_notes=release_notes, prerelease=new_version.is_prerelease, assets=assets, noop=opts.noop, ) except HTTPError as err: exception = err except UnexpectedResponse as err: exception = err help_message = str.join( " ", [ "Before re-running, make sure to clean up any artifacts", "on the hvcs that may have already been created.", ], ) help_message = str.join( "\n", [ "Unexpected response from remote VCS!", help_message, ], ) except Exception as err: # noqa: BLE001 # TODO: Remove this catch-all exception handler in the future exception = err finally: if exception is not None: log.exception(exception) click.echo(str(exception), err=True) if help_message: click.echo(help_message, err=True) click.echo( f"Failed to create release on {hvcs_client.__class__.__name__}!", err=True, ) ctx.exit(1)