From 378fb0399f9feb65a51ad3133bf03dfe885c339e Mon Sep 17 00:00:00 2001 From: Sam Kendziora Date: Tue, 20 Oct 2026 23:13:52 +0000 Subject: [PATCH] ci(release): check VERSION before creating a release tag (#1321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(release): add a VERSION readiness check for release tags Signed-off-by: Samantha Coyle * ci(release): reuse the VERSION check in the publish workflow Signed-off-by: Samantha Coyle * ci(release): gate tag creation on VERSION matching the release Signed-off-by: Samantha Coyle * fix: address review — harden release version check and tag workflow Signed-off-by: Samantha Coyle --------- Signed-off-by: Samantha Coyle (cherry picked from commit f7848434d42ed79e4f7d48887fcf24c33e6242d4) Signed-off-by: dapr-bot --- .github/workflows/build-tag.yaml | 15 +- .github/workflows/release-tag.yaml | 66 +++++++++ RELEASE.md | 64 +++++---- tests/test_check_release_version.py | 184 +++++++++++++++++++++++++ tools/check_release_version.py | 206 ++++++++++++++++++++++++++++ 5 files changed, 497 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/release-tag.yaml create mode 100644 tests/test_check_release_version.py create mode 100644 tools/check_release_version.py diff --git a/.github/workflows/build-tag.yaml b/.github/workflows/build-tag.yaml index 542554a44..a1515e56f 100644 --- a/.github/workflows/build-tag.yaml +++ b/.github/workflows/build-tag.yaml @@ -21,20 +21,7 @@ jobs: run: uv sync --frozen --all-packages --group dev - name: Verify tag matches a stable VERSION if: startsWith(github.ref, 'refs/tags/v') - run: | - uv run python -c " - import os - from importlib.metadata import version - tag = os.environ['GITHUB_REF'].removeprefix('refs/tags/v') - package_version = version('dapr') - assert tag == package_version, ( - f'Tag {os.environ[\"GITHUB_REF\"]!r} expects version={tag!r}, got {package_version!r}. ' - 'Bump VERSION before tagging, see RELEASE.md' - ) - assert '.dev' not in package_version, ( - f'Version {package_version!r} contains .dev. Release tags must point to a stable or rc version' - ) - " + run: uv run python tools/check_release_version.py --tag "$GITHUB_REF" - name: Run Autoformatter run: | uv run ruff check --fix && uv run ruff format diff --git a/.github/workflows/release-tag.yaml b/.github/workflows/release-tag.yaml new file mode 100644 index 000000000..a5c06d7bd --- /dev/null +++ b/.github/workflows/release-tag.yaml @@ -0,0 +1,66 @@ +# +# Copyright 2026 The Dapr Authors +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Creates the release tag only after checking that VERSION on the release +# branch holds the version being released. A hand-pushed tag is only checked +# by dapr-python-release once the tag already exists, which is how v1.19.0rc2 +# ended up tagged on a commit whose VERSION still said 1.19.0rc1. +name: dapr-python-release-tag + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release, e.g. 1.19.0rc2. Must match VERSION on the selected release-X.Y branch.' + required: true + type: string + +concurrency: + group: release-tag-${{ github.ref }} + cancel-in-progress: false + +jobs: + tag: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + - name: Set up Python 3.10 + uses: actions/setup-python@v7 + with: + python-version: '3.10' + - name: Verify VERSION is ready to release + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + python tools/check_release_version.py \ + --expected "$EXPECTED_VERSION" \ + --branch "$GITHUB_REF" \ + --require-untagged + - name: Create and push tag + env: + # A tag pushed with GITHUB_TOKEN does not trigger other workflows, + # so the bot token is what lets this tag start dapr-python-release. + # It is only handed to this step, not persisted by checkout. + BOT_TOKEN: ${{ secrets.DAPR_BOT_TOKEN }} + run: | + release_tag="v$(tr -d '[:space:]' < VERSION)" + git tag "$release_tag" "$GITHUB_SHA" + git push "https://x-access-token:${BOT_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + "refs/tags/$release_tag" + echo "Pushed $release_tag at $GITHUB_SHA; dapr-python-release publishes it." >> "$GITHUB_STEP_SUMMARY" diff --git a/RELEASE.md b/RELEASE.md index e2ee35adf..319df431d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -51,9 +51,6 @@ The warning and the `flask_dapr` shim are kept through 1.21 and removed in support keeps it alive until 1.21, so this window covers every user who could still be migrating. -You can also create the tag via a **GitHub Release**, which auto-creates the tag and provides -a changelog UI. - ## Overview Releases follow a branching model where `main` is always the development trunk. @@ -104,9 +101,10 @@ minor as `.dev` — once `release-X.Y` is forked, `main` advances from ## Remote convention -All commands below use `upstream` to refer to the **canonical Dapr repository** +All `git` commands below use `upstream` to refer to the **canonical Dapr repository** (`https://github.com/dapr/python-sdk`), not your personal fork. -If your local remote is named differently, substitute accordingly. +If your local remote is named differently, substitute accordingly. The `gh` commands name the +repository with `--repo` instead. ## Scenario A — Fork a new release branch @@ -131,19 +129,16 @@ changes the `VERSION` file from `X.Y.0.dev` → `X.Y.0rc0`. Open a PR targeting `main` that changes `VERSION` from the previous dev version to `X.Y.0.dev`. -### 4. Push the tag +### 4. Create the tag -Once the version bump PR on `release-X.Y` is merged, create and push the tag from the -**tip of `release-X.Y`**: +Once the version bump PR on `release-X.Y` is merged, run the `dapr-python-release-tag` +workflow on `release-X.Y` (see [Creating the tag](#creating-the-tag)): ```bash -git checkout release-X.Y -git pull upstream release-X.Y - -git tag vX.Y.0rc0 && git push upstream vX.Y.0rc0 +gh workflow run dapr-python-release-tag --repo dapr/python-sdk --ref release-X.Y -f version=X.Y.0rc0 ``` -The tag push triggers the `dapr-python-release` workflow which builds and uploads +The tag triggers the `dapr-python-release` workflow which builds and uploads all packages to PyPI. ## Scenario B — Ship a new release candidate @@ -154,15 +149,12 @@ Perform this when you want to publish `X.Y.0rcN` (N ≥ 1) from an existing `rel Open a PR **targeting `release-X.Y`** that changes `VERSION` from `X.Y.0rc(N-1)` → `X.Y.0rcN`. -### 2. Push the tag +### 2. Create the tag Once the PR is merged: ```bash -git checkout release-X.Y -git pull upstream release-X.Y - -git tag vX.Y.0rcN && git push upstream vX.Y.0rcN +gh workflow run dapr-python-release-tag --repo dapr/python-sdk --ref release-X.Y -f version=X.Y.0rcN ``` ## Scenario C — Ship the stable release (and patch releases) @@ -175,19 +167,43 @@ initial `X.Y.0` or a patch release (`X.Y.1`, `X.Y.2`, …). Open a PR **targeting `release-X.Y`** that drops the `rcN` suffix in `VERSION`: `X.Y.ZrcN` → `X.Y.Z`. -### 2. Push the tag +### 2. Create the tag Once the PR is merged: ```bash -git checkout release-X.Y -git pull upstream release-X.Y +gh workflow run dapr-python-release-tag --repo dapr/python-sdk --ref release-X.Y -f version=X.Y.Z +``` + +## Creating the tag -git tag vX.Y.Z && git push upstream vX.Y.Z +The `dapr-python-release-tag` workflow is the supported way to tag a release. Before it +creates `vX.Y.Z` at the tip of the selected branch, it runs `tools/check_release_version.py`, +which fails when: + +- `VERSION` does not match the version you asked to release, which usually means the bump + PR has not been merged yet; +- the branch is not `release-X.Y`, or `VERSION` belongs to a different `X.Y` series; +- `VERSION` is a `.dev` version; +- a tag for `VERSION` already exists. + +You can run the same check locally before triggering the workflow, from an up-to-date checkout +of `release-X.Y` with all tags fetched: + +```bash +git checkout release-X.Y && git pull upstream release-X.Y && git fetch upstream --tags +python tools/check_release_version.py --expected X.Y.Z --branch release-X.Y --require-untagged ``` -Alternatively, create a **GitHub Release** targeting `release-X.Y` with tag `vX.Y.Z` — this -creates the tag and triggers the publish workflow automatically. +The workflow runs the copy of itself found on the selected branch, so it only works on release +branches that contain `.github/workflows/release-tag.yaml` and `tools/check_release_version.py`. +Branches cut before these were added (`release-1.18` and older, and `release-1.19` until they are +backported) still tag by hand from the tip of the branch: +`git tag vX.Y.Z && git push upstream vX.Y.Z`. + +A tag pushed by hand, or created through a GitHub Release, skips these checks. The publish +workflow still compares the tag to `VERSION` and refuses to upload on a mismatch. By then the +tag already exists, though, and has to be deleted and recreated once `VERSION` is fixed. ## Backporting changes diff --git a/tests/test_check_release_version.py b/tests/test_check_release_version.py new file mode 100644 index 000000000..4e11de47b --- /dev/null +++ b/tests/test_check_release_version.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Covers tools/check_release_version.py, the gate the release workflows run +before a tag is created and again before anything is published. +""" + +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path +from types import ModuleType +from unittest import mock + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPT_PATH = REPO_ROOT / 'tools' / 'check_release_version.py' + + +def load_script() -> ModuleType: + """Loads the script as a module; tools/ is not an importable package.""" + module_spec = importlib.util.spec_from_file_location('check_release_version', SCRIPT_PATH) + assert module_spec is not None and module_spec.loader is not None + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + return module + + +crv = load_script() + + +class ReleaseVersionFormatTests(unittest.TestCase): + def test_accepts_stable_and_rc_versions(self) -> None: + for version in ('1.19.0', '1.19.0rc0', '1.19.0rc12', '1.18.3', '2.0.1rc1'): + with self.subTest(version=version): + crv.check_release_version(version) + + def test_rejects_dev_and_non_normalized_versions(self) -> None: + rejected_versions = ( + '1.19.0.dev', + '1.19.0-rc1', + '1.19.0.rc1', + '1.19', + 'v1.19.0', + '', + '1.19.0rc', + '1.19.0.post1', + '1.19.0rc1.dev0', + '01.19.0', + '1.19.0rc01', + '\u0661.19.0', + ) + for version in rejected_versions: + with self.subTest(version=version): + with self.assertRaises(crv.ReleaseVersionError): + crv.check_release_version(version) + + +class ExpectedVersionTests(unittest.TestCase): + def test_matching_version_passes_with_or_without_tag_prefix(self) -> None: + crv.check_expected_version('1.19.0rc2', '1.19.0rc2') + crv.check_expected_version('1.19.0rc2', 'v1.19.0rc2') + + def test_unbumped_version_fails(self) -> None: + with self.assertRaisesRegex(crv.ReleaseVersionError, 'Bump VERSION'): + crv.check_expected_version('1.19.0rc1', '1.19.0rc2') + + +class BranchTests(unittest.TestCase): + def test_version_in_branch_series_passes(self) -> None: + crv.check_branch('1.19.0rc2', 'release-1.19') + crv.check_branch('1.19.1', 'refs/heads/release-1.19') + + def test_version_from_another_series_fails(self) -> None: + with self.assertRaisesRegex(crv.ReleaseVersionError, 'release-1.19'): + crv.check_branch('1.20.0rc0', 'release-1.19') + + def test_non_release_branch_fails(self) -> None: + rejected_branches = ( + 'main', + 'release-1', + 'feature/release-1.19', + 'release-1.19-hotfix', + 'refs/tags/release-1.19', + 'release-1.19.0', + ) + for branch in rejected_branches: + with self.subTest(branch=branch): + with self.assertRaises(crv.ReleaseVersionError): + crv.check_branch('1.19.0', branch) + + +class TagTests(unittest.TestCase): + def test_matching_tag_passes(self) -> None: + crv.check_tag('1.19.0rc2', 'v1.19.0rc2') + crv.check_tag('1.19.0rc2', 'refs/tags/v1.19.0rc2') + + def test_tag_ahead_of_version_fails(self) -> None: + with self.assertRaisesRegex(crv.ReleaseVersionError, 'Bump VERSION before tagging'): + crv.check_tag('1.19.0rc1', 'v1.19.0rc2') + + +class UntaggedTests(unittest.TestCase): + def test_new_version_passes(self) -> None: + crv.check_untagged('1.19.0rc2', {'v1.19.0rc0', 'v1.19.0rc1'}) + + def test_already_released_version_fails(self) -> None: + with self.assertRaisesRegex(crv.ReleaseVersionError, 'already exists'): + crv.check_untagged('1.19.0rc1', {'v1.19.0rc0', 'v1.19.0rc1'}) + + +class ListGitTagsTests(unittest.TestCase): + def test_git_failure_raises_release_version_error(self) -> None: + git_error = subprocess.CalledProcessError(128, ['git'], stderr='not a git repository\n') + with mock.patch.object(crv.subprocess, 'run', side_effect=git_error): + with self.assertRaisesRegex(crv.ReleaseVersionError, 'not a git repository'): + crv.list_git_tags() + + def test_missing_git_raises_release_version_error(self) -> None: + with mock.patch.object(crv.subprocess, 'run', side_effect=FileNotFoundError('git')): + with self.assertRaisesRegex(crv.ReleaseVersionError, 'Cannot list git tags'): + crv.list_git_tags() + + +class MainTests(unittest.TestCase): + def setUp(self) -> None: + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + self.version_path = Path(temp_dir.name) / 'VERSION' + self.version_path.write_text('1.19.0rc1\n', encoding='utf-8') + + def run_main(self, *args: str) -> int: + exit_code: int = crv.main(['--version-file', str(self.version_path), *args]) + return exit_code + + def test_passes_when_every_check_holds(self) -> None: + exit_code = self.run_main('--expected', '1.19.0rc1', '--branch', 'release-1.19') + self.assertEqual(exit_code, 0) + + def test_fails_when_version_was_not_bumped(self) -> None: + with self.assertLogs(crv.logger, level='ERROR') as captured_logs: + exit_code = self.run_main('--expected', '1.19.0rc2', '--branch', 'release-1.19') + self.assertEqual(exit_code, 1) + self.assertIn('Bump VERSION', captured_logs.output[0]) + + def test_fails_when_version_is_already_tagged(self) -> None: + existing_tags = {'v1.19.0rc0', 'v1.19.0rc1'} + with mock.patch.object(crv, 'list_git_tags', return_value=existing_tags): + with self.assertLogs(crv.logger, level='ERROR') as captured_logs: + exit_code = self.run_main('--require-untagged') + self.assertEqual(exit_code, 1) + self.assertIn('already exists', captured_logs.output[0]) + + def test_passes_when_version_is_untagged(self) -> None: + with mock.patch.object(crv, 'list_git_tags', return_value={'v1.19.0rc0'}): + exit_code = self.run_main('--require-untagged') + self.assertEqual(exit_code, 0) + + def test_fails_when_version_file_is_missing(self) -> None: + self.version_path.unlink() + with self.assertLogs(crv.logger, level='ERROR') as captured_logs: + exit_code = self.run_main() + self.assertEqual(exit_code, 1) + self.assertIn('Cannot read', captured_logs.output[0]) + + def test_fails_when_tag_does_not_match(self) -> None: + with self.assertLogs(crv.logger, level='ERROR'): + exit_code = self.run_main('--tag', 'v1.19.0rc2') + self.assertEqual(exit_code, 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/check_release_version.py b/tools/check_release_version.py new file mode 100644 index 000000000..f5c624526 --- /dev/null +++ b/tools/check_release_version.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- + +""" +Copyright 2026 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Checks that the VERSION file is ready to be released. + +The release-tag workflow runs this before it creates a tag, so a release branch +whose VERSION was never bumped fails before any tag exists. The publish +workflow runs it again on the tag itself before uploading to PyPI. + +Usage: + python tools/check_release_version.py --expected 1.19.0rc2 \ + --branch release-1.19 --require-untagged + python tools/check_release_version.py --tag v1.19.0rc2 +""" + +from __future__ import annotations + +import argparse +import logging +import os +import re +import subprocess +import sys +from collections.abc import Iterable, Sequence +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VERSION_FILE = REPO_ROOT / 'VERSION' +TAG_PREFIX = 'v' +TAG_REF_PREFIX = 'refs/tags/' +BRANCH_REF_PREFIX = 'refs/heads/' +RELEASE_DOC_HINT = 'See RELEASE.md.' +GIT_TIMEOUT_SECONDS = 30 + +# Only PEP 440-normalized stable or rc versions (ASCII digits, no leading zeros), +# so the tag, the VERSION file and the wheel metadata all spell the version identically. +VERSION_NUMBER = r'(?:0|[1-9][0-9]*)' +RELEASE_SERIES = rf'(?P{VERSION_NUMBER}\.{VERSION_NUMBER})' +RELEASE_VERSION_PATTERN = re.compile(rf'{RELEASE_SERIES}\.{VERSION_NUMBER}(?:rc{VERSION_NUMBER})?') +RELEASE_BRANCH_PATTERN = re.compile(rf'release-{RELEASE_SERIES}') + +logger = logging.getLogger('check_release_version') + + +class ReleaseVersionError(Exception): + """Raised when VERSION is not ready for the requested release.""" + + +def read_version(version_path: Path = VERSION_FILE) -> str: + """Returns the version string stored in the VERSION file.""" + try: + return version_path.read_text(encoding='utf-8').strip() + except OSError as error: + raise ReleaseVersionError(f'Cannot read {version_path}: {error}') from error + + +def strip_tag_prefix(tag: str) -> str: + """Turns `refs/tags/v1.2.3` or `v1.2.3` into `1.2.3`.""" + return tag.removeprefix(TAG_REF_PREFIX).removeprefix(TAG_PREFIX) + + +def check_release_version(version: str) -> None: + """Fails unless the version is a stable or rc release, never a .dev one.""" + if RELEASE_VERSION_PATTERN.fullmatch(version): + return + raise ReleaseVersionError( + f'VERSION {version!r} is not a releasable version. Release tags must point to a ' + f'stable (X.Y.Z) or rc (X.Y.ZrcN) version, never .dev. {RELEASE_DOC_HINT}' + ) + + +def check_expected_version(version: str, expected: str) -> None: + """Fails unless VERSION holds the version the maintainer intends to release.""" + expected_version = strip_tag_prefix(expected) + if version == expected_version: + return + raise ReleaseVersionError( + f'Releasing {expected_version!r} but VERSION is {version!r}. Bump VERSION to ' + f'{expected_version!r} on the release branch and merge that first. {RELEASE_DOC_HINT}' + ) + + +def check_branch(version: str, branch: str) -> None: + """Fails unless the branch is release-X.Y and VERSION belongs to the X.Y series.""" + branch_name = branch.removeprefix(BRANCH_REF_PREFIX) + branch_match = RELEASE_BRANCH_PATTERN.fullmatch(branch_name) + if branch_match is None: + raise ReleaseVersionError( + f'Releases are cut from release-X.Y branches, not {branch_name!r}. {RELEASE_DOC_HINT}' + ) + version_match = RELEASE_VERSION_PATTERN.fullmatch(version) + version_series = version_match['series'] if version_match else None + if version_series == branch_match['series']: + return + raise ReleaseVersionError( + f'VERSION {version!r} does not belong on {branch_name!r}. {RELEASE_DOC_HINT}' + ) + + +def check_tag(version: str, tag: str) -> None: + """Fails unless the tag names exactly the version in VERSION.""" + tag_version = strip_tag_prefix(tag) + if version == tag_version: + return + raise ReleaseVersionError( + f'Tag {tag!r} expects version={tag_version!r}, got {version!r}. ' + f'Bump VERSION before tagging. {RELEASE_DOC_HINT}' + ) + + +def check_untagged(version: str, existing_tags: Iterable[str]) -> None: + """Fails if VERSION was already released, which means it was never bumped.""" + release_tag = f'{TAG_PREFIX}{version}' + if release_tag not in set(existing_tags): + return + raise ReleaseVersionError( + f'Tag {release_tag!r} already exists, so VERSION {version!r} has already been ' + f'released. Bump VERSION to the next version first. {RELEASE_DOC_HINT}' + ) + + +def list_git_tags() -> set[str]: + """Returns every release tag known to the local clone. + + Only tags already fetched are seen, so the caller must fetch tags first. + """ + git_command = ['git', 'tag', '--list', f'{TAG_PREFIX}*'] + try: + git_result = subprocess.run( + git_command, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + timeout=GIT_TIMEOUT_SECONDS, + ) + except subprocess.CalledProcessError as error: + raise ReleaseVersionError(f'git tag failed: {error.stderr.strip()}') from error + except (subprocess.SubprocessError, OSError) as error: + raise ReleaseVersionError(f'Cannot list git tags: {error}') from error + return set(git_result.stdout.split()) + + +def parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + """Parses the command line; `None` reads `sys.argv`.""" + parser = argparse.ArgumentParser(description='Checks that VERSION is ready to be released.') + parser.add_argument('--version-file', type=Path, default=VERSION_FILE) + parser.add_argument('--expected', help='version the maintainer intends to release') + parser.add_argument('--branch', help='release branch the tag will be cut from') + parser.add_argument('--tag', help='tag that triggered the release') + parser.add_argument( + '--require-untagged', + action='store_true', + help='fail if a tag for VERSION already exists in the local clone', + ) + return parser.parse_args(argv) + + +def run_checks(args: argparse.Namespace) -> str: + """Runs every check the arguments ask for and returns the validated version.""" + version = read_version(args.version_file) + check_release_version(version) + if args.expected is not None: + check_expected_version(version, args.expected) + if args.branch is not None: + check_branch(version, args.branch) + if args.tag is not None: + check_tag(version, args.tag) + if args.require_untagged: + check_untagged(version, list_git_tags()) + return version + + +def configure_logging() -> None: + # Inside GitHub Actions, ::error:: surfaces the message on the run summary. + in_github_actions = os.environ.get('GITHUB_ACTIONS') == 'true' + log_format = '::error::%(message)s' if in_github_actions else '%(levelname)s: %(message)s' + logging.basicConfig(level=logging.INFO, format=log_format, stream=sys.stderr) + + +def main(argv: Sequence[str] | None = None) -> int: + """Runs the requested checks and returns the process exit code.""" + args = parse_args(argv) + try: + version = run_checks(args) + except ReleaseVersionError as error: + logger.error('%s', error) + return 1 + logger.info('VERSION %s is ready to release', version) + return 0 + + +if __name__ == '__main__': + configure_logging() + sys.exit(main())