diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index d3ce5e0..76a2bbf 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,5 +1,17 @@ name: Build and Push Docker image +# CalVer Release Workflow +# +# Automatically creates a CalVer release on every push to main. +# Version format: YYYY.MM.DD (e.g., 2026.01.16) +# If multiple releases happen on the same day, adds sequence: YYYY.MM.DD.2, YYYY.MM.DD.3, etc. +# +# Docker tags created: +# - CalVer tag (e.g., 2026.01.16) +# - Branch name (e.g., main) +# - Git short hash (e.g., main-a1b2c3d) +# - latest (for main branch only) + on: push: branches: [main, release-test] @@ -9,12 +21,69 @@ jobs: build: runs-on: ubuntu-latest permissions: - contents: read + contents: write # Need write for creating tags packages: write steps: - name: Checkout code uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for tags + + - name: Generate CalVer version + id: calver + run: | + # Get today's date in YYYY.MM.DD format + TODAY=$(date +"%Y.%m.%d") + + # Get all existing tags for today + EXISTING_TAGS=$(git tag -l "${TODAY}*" | sort -V) + + if [ -z "$EXISTING_TAGS" ]; then + # No tags for today, use base date + VERSION="${TODAY}" + else + # Find the highest sequence number + LAST_TAG=$(echo "$EXISTING_TAGS" | tail -1) + + if [[ "$LAST_TAG" == "$TODAY" ]]; then + # First tag was just the date, next is .2 + VERSION="${TODAY}.2" + elif [[ "$LAST_TAG" =~ ^${TODAY}\.([0-9]+)$ ]]; then + # Extract sequence number and increment + SEQ="${BASH_REMATCH[1]}" + NEXT_SEQ=$((SEQ + 1)) + VERSION="${TODAY}.${NEXT_SEQ}" + else + # Fallback + VERSION="${TODAY}.2" + fi + fi + + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Generated CalVer version: ${VERSION}" + + - name: Get git commit info + id: git + run: | + echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + echo "full_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT + echo "build_time=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> $GITHUB_OUTPUT + + - name: Create git tag + if: github.ref == 'refs/heads/main' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Check if tag already exists + if git rev-parse "${{ steps.calver.outputs.version }}" >/dev/null 2>&1; then + echo "Tag ${{ steps.calver.outputs.version }} already exists, skipping" + else + git tag -a "${{ steps.calver.outputs.version }}" -m "Release ${{ steps.calver.outputs.version }}" + git push origin "${{ steps.calver.outputs.version }}" + echo "Created and pushed tag ${{ steps.calver.outputs.version }}" + fi - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -33,11 +102,19 @@ jobs: images: | public.ecr.aws/r4g1k2s3/vcon-dev/vcon-server tags: | + # CalVer tag (e.g., 2026.01.16) + type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} + # Branch name type=ref,event=branch + # PR number type=ref,event=pr + # Semver tags (for manual v* tags) type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} + # Git sha with branch prefix type=sha,prefix={{branch}}- + # Latest tag for main branch + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push Docker image uses: docker/build-push-action@v5 @@ -50,3 +127,56 @@ jobs: cache-to: type=gha,mode=max tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VCON_SERVER_VERSION=${{ steps.calver.outputs.version }} + VCON_SERVER_GIT_COMMIT=${{ steps.git.outputs.short_sha }} + VCON_SERVER_BUILD_TIME=${{ steps.git.outputs.build_time }} + + - name: Create GitHub Release + if: github.ref == 'refs/heads/main' + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.calver.outputs.version }} + name: Release ${{ steps.calver.outputs.version }} + body: | + ## Release ${{ steps.calver.outputs.version }} + + **Commit:** ${{ steps.git.outputs.short_sha }} + **Build Time:** ${{ steps.git.outputs.build_time }} + + ### Docker Images + + Pull using CalVer: + ```bash + docker pull public.ecr.aws/r4g1k2s3/vcon-dev/vcon-server:${{ steps.calver.outputs.version }} + ``` + + Pull using git hash: + ```bash + docker pull public.ecr.aws/r4g1k2s3/vcon-dev/vcon-server:main-${{ steps.git.outputs.short_sha }} + ``` + + Pull latest: + ```bash + docker pull public.ecr.aws/r4g1k2s3/vcon-dev/vcon-server:latest + ``` + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "## Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| **Version** | ${{ steps.calver.outputs.version }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Git Commit** | ${{ steps.git.outputs.short_sha }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Build Time** | ${{ steps.git.outputs.build_time }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Branch** | ${{ github.ref_name }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Tags" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index 04b423d..f1c781d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,15 @@ FROM python:3.12.2 +# Build arguments for version information (injected by CI/CD) +ARG VCON_SERVER_VERSION=dev +ARG VCON_SERVER_GIT_COMMIT=unknown +ARG VCON_SERVER_BUILD_TIME=unknown + +# Set version info as environment variables (available at runtime) +ENV VCON_SERVER_VERSION=${VCON_SERVER_VERSION} +ENV VCON_SERVER_GIT_COMMIT=${VCON_SERVER_GIT_COMMIT} +ENV VCON_SERVER_BUILD_TIME=${VCON_SERVER_BUILD_TIME} + RUN apt-get update && \ apt-get install -y libavdevice-dev ffmpeg diff --git a/server/api.py b/server/api.py index 848b3f6..c38d7ca 100644 --- a/server/api.py +++ b/server/api.py @@ -46,6 +46,11 @@ from lib.context_utils import store_context_async, extract_otel_trace_context from lib.logging_utils import init_logger import redis_mgr +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request as StarletteRequest +from starlette.responses import Response + +from version import get_version_info, get_version_string, get_version, get_git_commit # OpenTelemetry trace context extraction is now in lib.context_utils from settings import ( @@ -188,10 +193,73 @@ async def on_shutdown() -> None: CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"] ) + +# Version header middleware - adds version info to every response +class VersionHeaderMiddleware(BaseHTTPMiddleware): + """Middleware that adds version information to all API responses. + + Adds the following headers to every response: + - X-Vcon-Server-Version: CalVer version (e.g., "2026.01.18") + - X-Vcon-Server-Commit: Git short hash (e.g., "a1b2c3d") + + This makes it easy to identify which version of the server handled + a request, useful for debugging, monitoring, and APM tools. + """ + + async def dispatch(self, request: StarletteRequest, call_next) -> Response: + response = await call_next(request) + response.headers["X-Vcon-Server-Version"] = get_version() + response.headers["X-Vcon-Server-Commit"] = get_git_commit() + return response + + +app.add_middleware(VersionHeaderMiddleware) + api_router = APIRouter() external_router = APIRouter() +# Version endpoint - publicly accessible (no auth required) +@app.get( + "/version", + summary="Get server version", + description="Returns the server version information including CalVer version, git commit, and build time", + tags=["system"], +) +async def version_endpoint() -> JSONResponse: + """Get the server version information. + + Returns version details including: + - CalVer version (e.g., "2026.01.16") + - Git commit hash + - Build timestamp + + This endpoint does not require authentication. + + Returns: + JSONResponse containing version information + """ + return JSONResponse(content=get_version_info()) + + +@app.get( + "/health", + summary="Health check", + description="Returns server health status and version", + tags=["system"], +) +async def health_check() -> JSONResponse: + """Health check endpoint. + + Returns: + JSONResponse with status and version info + """ + return JSONResponse(content={ + "status": "healthy", + "version": get_version_info() + }) + + class Vcon(BaseModel): """Pydantic model representing a vCon (Voice Conversation) record. diff --git a/server/main.py b/server/main.py index 22156a2..c155175 100644 --- a/server/main.py +++ b/server/main.py @@ -17,6 +17,7 @@ import redis_mgr from config import get_config +from version import get_version_string, get_version_info from dlq_utils import get_ingress_list_dlq_name from lib.context_utils import retrieve_context, store_context_sync, extract_otel_trace_context from lib.error_tracking import init_error_tracker @@ -500,6 +501,20 @@ def main() -> None: processing loop that pulls vCons from ingress queues and processes them through their configured chains. """ + # Print version information on startup + version_info = get_version_info() + logger.info( + "Starting %s", + get_version_string(), + extra={"version_info": version_info} + ) + logger.info( + "Version: %s | Commit: %s | Built: %s", + version_info["version"], + version_info["git_commit"], + version_info["build_time"] + ) + logger.info("Initializing vCon server") global config config = get_config() diff --git a/server/version.py b/server/version.py new file mode 100644 index 0000000..49d2c1a --- /dev/null +++ b/server/version.py @@ -0,0 +1,84 @@ +"""Version information for the vCon server. + +This module provides version information that is injected at Docker build time. +When running locally (outside Docker), it falls back to "dev" values. + +Environment Variables (set during Docker build): + VCON_SERVER_VERSION: CalVer version (e.g., "2026.01.16.1") + VCON_SERVER_GIT_COMMIT: Git commit hash (short, e.g., "a1b2c3d") + VCON_SERVER_BUILD_TIME: ISO timestamp of when the image was built + +Usage: + from version import get_version, get_version_info + + # Get just the version string + version = get_version() # "2026.01.16.1" or "dev" + + # Get full version info dict + info = get_version_info() + # { + # "version": "2026.01.16.1", + # "git_commit": "a1b2c3d", + # "build_time": "2026-01-16T10:30:00Z" + # } +""" + +import os +from typing import Dict + + +# Version info from environment (injected at Docker build time) +VERSION = os.environ.get("VCON_SERVER_VERSION", "dev") +GIT_COMMIT = os.environ.get("VCON_SERVER_GIT_COMMIT", "unknown") +BUILD_TIME = os.environ.get("VCON_SERVER_BUILD_TIME", "unknown") + + +def get_version() -> str: + """Get the current version string. + + Returns: + The CalVer version string (e.g., "2026.01.16.1") or "dev" if not set. + """ + return VERSION + + +def get_git_commit() -> str: + """Get the git commit hash. + + Returns: + The short git commit hash (e.g., "a1b2c3d") or "unknown" if not set. + """ + return GIT_COMMIT + + +def get_build_time() -> str: + """Get the build timestamp. + + Returns: + ISO timestamp of when the image was built, or "unknown" if not set. + """ + return BUILD_TIME + + +def get_version_info() -> Dict[str, str]: + """Get complete version information as a dictionary. + + Returns: + Dictionary containing version, git_commit, and build_time. + """ + return { + "version": VERSION, + "git_commit": GIT_COMMIT, + "build_time": BUILD_TIME, + } + + +def get_version_string() -> str: + """Get a formatted version string for display. + + Returns: + Formatted string like "vCon Server v2026.01.16.1 (a1b2c3d)" + """ + if VERSION == "dev": + return "vCon Server (development)" + return f"vCon Server v{VERSION} ({GIT_COMMIT})"