diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f1ee09d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,46 @@ +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: 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 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: | + 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/main.py b/main.py index 0a6fbad..aa67d28 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,19 +867,21 @@ 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 + # 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": 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"