fix(ci): gate deploys on passing tags + fix markets feature flag test

- CI now creates v<pipeline_iid> 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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-24 03:36:59 +01:00
parent 7f3bde56b6
commit 6c1dc90a8d
3 changed files with 58 additions and 6 deletions

View File

@@ -1,5 +1,6 @@
stages: stages:
- test - test
- tag
test: test:
stage: test stage: test
@@ -14,6 +15,15 @@ test:
- if: $CI_COMMIT_BRANCH == "master" - if: $CI_COMMIT_BRANCH == "master"
- if: $CI_PIPELINE_SOURCE == "merge_request_event" - 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). # 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. # No CI secrets needed — zero SSH keys, zero deploy credentials.

View File

@@ -17,12 +17,14 @@ Usage:
""" """
import importlib import importlib
import json
import logging import logging
import os import os
import subprocess import subprocess
import sys import sys
import time import time
import tomllib import tomllib
import urllib.request
from collections import defaultdict from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime 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", "") ALERT_WEBHOOK_URL = os.getenv("ALERT_WEBHOOK_URL", "")
NTFY_TOKEN = os.getenv("NTFY_TOKEN", "") NTFY_TOKEN = os.getenv("NTFY_TOKEN", "")
WORKFLOWS_PATH = Path(os.getenv("WORKFLOWS_PATH", "infra/supervisor/workflows.toml")) 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 = { NAMED_SCHEDULES = {
"hourly": "0 * * * *", "hourly": "0 * * * *",
@@ -271,10 +276,47 @@ def web_code_changed() -> bool:
return bool(result.stdout.strip()) 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<n> 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: def git_pull_and_sync() -> None:
"""Pull latest code and sync dependencies.""" """Checkout the latest passing release tag and sync dependencies.
run_shell("git fetch origin master")
run_shell("git switch --discard-changes --detach origin/master") A tag v<N> 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") run_shell("uv sync --all-packages")

View File

@@ -117,7 +117,7 @@ class TestMigration0019:
assert "description" in cols assert "description" in cols
assert "updated_at" 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") db_path = str(tmp_path / "test.db")
migrate(db_path) migrate(db_path)
conn = sqlite3.connect(db_path) conn = sqlite3.connect(db_path)
@@ -125,7 +125,7 @@ class TestMigration0019:
"SELECT enabled FROM feature_flags WHERE name = 'markets'" "SELECT enabled FROM feature_flags WHERE name = 'markets'"
).fetchone() ).fetchone()
conn.close() 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): def test_seeds_payments_disabled(self, tmp_path):
db_path = str(tmp_path / "test.db") db_path = str(tmp_path / "test.db")