From 2e3abdd448685b17259a5d48241020dd5f76e834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Fri, 24 Jul 2026 14:49:04 +0200 Subject: [PATCH 1/3] add single-source CLI version management and release workflow --- .github/workflows/release.yml | 38 +++++++++++++++++++++++++++++++++++ config.py | 4 ---- core/utils_sync.py | 3 ++- pyproject.toml | 5 ++++- tests/test_utils_sync.py | 4 ++-- utils/version.py | 1 + 6 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 utils/version.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9251354 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Version, no leading v (e.g. 1.1.5)" + required: true + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate version and guard against existing tag + run: | + v="${{ inputs.version }}" + echo "$v" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "Bad version format: $v"; exit 1; } + if git rev-parse "v$v" >/dev/null 2>&1; then echo "Tag v$v already exists"; exit 1; fi + + - name: Bump utils/version.py + run: | + v="${{ inputs.version }}" + sed -i "s/^__version__ = .*/__version__ = \"$v\"/" utils/version.py + grep -q "^__version__ = \"$v\"$" utils/version.py || { echo "sed did not update version"; exit 1; } + + - name: Commit, tag, and push to main + run: | + v="${{ inputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git commit -am "Release v$v" + git tag "v$v" + git push origin HEAD --follow-tags diff --git a/config.py b/config.py index 3cb20e6..7192d34 100644 --- a/config.py +++ b/config.py @@ -13,11 +13,7 @@ 2. Click your user profile (top right corner). 3. Select 'Keys' from the menu. 4. Click 'New Key' and copy the provided key. - -Please do not modify CLI_VERSION; it is used for debugging purposes. """ -CLI_VERSION = "v1.0.0" - HOST = "" KEY = "" diff --git a/core/utils_sync.py b/core/utils_sync.py index a8ecac0..bb9db3c 100644 --- a/core/utils_sync.py +++ b/core/utils_sync.py @@ -10,6 +10,7 @@ from typing import Any from core.utils_db import load_data +from utils.version import __version__ # Configure logger logger = logging.getLogger("core.engine.sync") @@ -60,7 +61,7 @@ def _build_payload( for c in cost_rows ] - engine_version = getattr(config, "CLI_VERSION", "v1.0.0").strip() + engine_version = f"v{__version__}" now = int(time.time()) payload: dict[str, Any] = { diff --git a/pyproject.toml b/pyproject.toml index cca17d4..bcf6bd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cloudexit" -version = "1.0.0" +dynamic = ["version"] description = "Open-source cloud exit assessment CLI for evaluating cloud lock-in risk." readme = "README.md" requires-python = ">=3.12" @@ -33,6 +33,9 @@ dev = [ [project.scripts] cloudexit = "main:main" +[tool.setuptools.dynamic] +version = {attr = "utils.version.__version__"} + [tool.setuptools] py-modules = ["main", "config"] diff --git a/tests/test_utils_sync.py b/tests/test_utils_sync.py index b26f266..6c0694c 100644 --- a/tests/test_utils_sync.py +++ b/tests/test_utils_sync.py @@ -19,7 +19,7 @@ def test_uses_host_from_environment_when_config_host_empty(self): patch.dict(os.environ, {"HOST": "env.exitcloud.io"}, clear=False), patch( "core.utils_sync.config", - types.SimpleNamespace(HOST="", CLI_VERSION="v1"), + types.SimpleNamespace(HOST=""), ), patch("core.utils_sync._build_payload", return_value={"sample": "payload"}), patch( @@ -47,7 +47,7 @@ def test_returns_clear_error_when_host_missing_everywhere(self): patch.dict(os.environ, {}, clear=True), patch( "core.utils_sync.config", - types.SimpleNamespace(HOST="", CLI_VERSION="v1"), + types.SimpleNamespace(HOST=""), ), ): result = post_assessment( diff --git a/utils/version.py b/utils/version.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/utils/version.py @@ -0,0 +1 @@ +__version__ = "1.0.0" From 10237a5afdc859eca909b4e8a30b0a689342b08a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Fri, 24 Jul 2026 15:13:00 +0200 Subject: [PATCH 2/3] add --version flag and harden release workflow guards --- .github/workflows/release.yml | 10 +++++++++- main.py | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9251354..f1ee09d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,11 +16,19 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Guard against non-main dispatch + run: | + if [ "${{ github.ref }}" != "refs/heads/main" ]; then + echo "Release must be dispatched from main, got ${{ github.ref }}"; exit 1 + fi + - name: Validate version and guard against existing tag run: | v="${{ inputs.version }}" echo "$v" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { echo "Bad version format: $v"; exit 1; } - if git rev-parse "v$v" >/dev/null 2>&1; then echo "Tag v$v already exists"; exit 1; fi + if git ls-remote --exit-code --tags origin "refs/tags/v$v" >/dev/null 2>&1; then + echo "Tag v$v already exists on origin"; exit 1 + fi - name: Bump utils/version.py run: | diff --git a/main.py b/main.py index 0a6fbad..22909f3 100644 --- a/main.py +++ b/main.py @@ -50,6 +50,7 @@ ) from utils.validate import validate_region, validate_config from utils import codes +from utils.version import __version__ # Configure the root logger to ensure logs propagate from all modules logging.basicConfig( @@ -780,6 +781,13 @@ def parse_arguments(): formatter_class=argparse.RawDescriptionHelpFormatter, ) + parser.add_argument( + "--version", + action="version", + version=f"cloudexit v{__version__}", + help="Show the CLI version and exit.", + ) + subparsers = parser.add_subparsers( dest="cloud_provider", help="Specify the cloud provider (aws or azure)." ) @@ -859,14 +867,16 @@ def parse_arguments(): def main(): + # Parse arguments first so --version/--help exit before any side effects + # (ASCII art, dataset download). + args = parse_arguments() + # Print ASCII art console.print(ascii_art, style="bold cyan") # Ensure latest dataset is available before proceeding initialize_dataset() - args = parse_arguments() - # Check if the cloud provider is specified if not args.cloud_provider: print_help_message() From ecbe26ddee8605ca71b04ce1d05110ea972a6200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bence=20H=C3=A9zs=C5=91?= Date: Fri, 24 Jul 2026 15:19:47 +0200 Subject: [PATCH 3/3] skip dataset download on help --- main.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index 22909f3..aa67d28 100644 --- a/main.py +++ b/main.py @@ -874,14 +874,14 @@ def main(): # Print ASCII art console.print(ascii_art, style="bold cyan") - # Ensure latest dataset is available before proceeding - initialize_dataset() - - # Check if the cloud provider is specified + # Nothing to do without a subcommand — show help before any dataset download. if not args.cloud_provider: print_help_message() return + # Ensure latest dataset is available before proceeding + initialize_dataset() + # Dispatch based on provided arguments try: if args.cloud_provider == "aws":