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:
- 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.

View File

@@ -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<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:
"""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<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")

View File

@@ -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")