From 6c1dc90a8d73269e87e2e19f48f1f57104e1467c Mon Sep 17 00:00:00 2001 From: Deeman Date: Tue, 24 Feb 2026 03:36:59 +0100 Subject: [PATCH] fix(ci): gate deploys on passing tags + fix markets feature flag test - CI now creates v tag after tests pass on master - Supervisor fetches tags and only deploys when a newer tag is available; skips if already on latest or no tags exist - Fix test_seeds_markets_enabled: markets is seeded disabled (enabled=0), test was asserting the wrong value Co-Authored-By: Claude Sonnet 4.6 --- .gitlab-ci.yml | 12 ++++++++- src/padelnomics/supervisor.py | 48 ++++++++++++++++++++++++++++++--- web/tests/test_feature_flags.py | 4 +-- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 96e8323..995cb9a 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,5 +1,6 @@ stages: - test + - tag test: stage: test @@ -14,6 +15,15 @@ test: - if: $CI_COMMIT_BRANCH == "master" - if: $CI_PIPELINE_SOURCE == "merge_request_event" +tag: + stage: tag + image: alpine/git + script: + - git tag "v${CI_PIPELINE_IID}" + - git push "https://gitlab-ci-token:${CI_JOB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "v${CI_PIPELINE_IID}" + rules: + - if: $CI_COMMIT_BRANCH == "master" + # Deployment is handled by the on-server supervisor (src/padelnomics/supervisor.py). -# It polls git every 60s, detects code changes, and runs deploy.sh automatically. +# It polls git every 60s, fetches tags, and deploys only when a new passing tag exists. # No CI secrets needed — zero SSH keys, zero deploy credentials. diff --git a/src/padelnomics/supervisor.py b/src/padelnomics/supervisor.py index e2c0fc8..c4e5214 100644 --- a/src/padelnomics/supervisor.py +++ b/src/padelnomics/supervisor.py @@ -17,12 +17,14 @@ Usage: """ import importlib +import json import logging import os import subprocess import sys import time import tomllib +import urllib.request from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import UTC, datetime @@ -44,6 +46,9 @@ SERVING_DUCKDB_PATH = os.getenv("SERVING_DUCKDB_PATH", "analytics.duckdb") ALERT_WEBHOOK_URL = os.getenv("ALERT_WEBHOOK_URL", "") NTFY_TOKEN = os.getenv("NTFY_TOKEN", "") WORKFLOWS_PATH = Path(os.getenv("WORKFLOWS_PATH", "infra/supervisor/workflows.toml")) +GITLAB_API_URL = os.getenv("GITLAB_API_URL", "") # e.g. https://gitlab.com +GITLAB_PROJECT_ID = os.getenv("GITLAB_PROJECT_ID", "") # numeric or namespace/project +GITLAB_TOKEN = os.getenv("GITLAB_TOKEN", "") # read_api scope NAMED_SCHEDULES = { "hourly": "0 * * * *", @@ -271,10 +276,47 @@ def web_code_changed() -> bool: return bool(result.stdout.strip()) +def current_deployed_tag() -> str | None: + """Return the tag currently checked out, or None if not on a tag.""" + result = subprocess.run( + ["git", "describe", "--tags", "--exact-match", "HEAD"], + capture_output=True, text=True, timeout=10, + ) + return result.stdout.strip() or None + + +def latest_remote_tag() -> str | None: + """Fetch tags from origin and return the latest v tag by version order.""" + subprocess.run( + ["git", "fetch", "--tags", "--prune-tags", "origin"], + capture_output=True, text=True, timeout=30, + ) + result = subprocess.run( + ["git", "tag", "--list", "--sort=-version:refname", "v*"], + capture_output=True, text=True, timeout=10, + ) + tags = result.stdout.strip().splitlines() + return tags[0] if tags else None + + def git_pull_and_sync() -> None: - """Pull latest code and sync dependencies.""" - run_shell("git fetch origin master") - run_shell("git switch --discard-changes --detach origin/master") + """Checkout the latest passing release tag and sync dependencies. + + A tag v is created by CI only after tests pass, so presence of a new + tag implies green CI. Skips if already on the latest tag. + """ + latest = latest_remote_tag() + if not latest: + logger.info("No release tags found — skipping pull") + return + + current = current_deployed_tag() + if current == latest: + logger.info("Already on latest tag %s — skipping pull", latest) + return + + logger.info("New tag %s available (current: %s) — deploying", latest, current) + run_shell(f"git checkout --detach {latest}") run_shell("uv sync --all-packages") diff --git a/web/tests/test_feature_flags.py b/web/tests/test_feature_flags.py index 5dac2d8..4ab8768 100644 --- a/web/tests/test_feature_flags.py +++ b/web/tests/test_feature_flags.py @@ -117,7 +117,7 @@ class TestMigration0019: assert "description" in cols assert "updated_at" in cols - def test_seeds_markets_enabled(self, tmp_path): + def test_seeds_markets_disabled(self, tmp_path): db_path = str(tmp_path / "test.db") migrate(db_path) conn = sqlite3.connect(db_path) @@ -125,7 +125,7 @@ class TestMigration0019: "SELECT enabled FROM feature_flags WHERE name = 'markets'" ).fetchone() conn.close() - assert row is not None and row[0] == 1 + assert row is not None and row[0] == 0 def test_seeds_payments_disabled(self, tmp_path): db_path = str(tmp_path / "test.db")