diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 8cd7c1c9..f2349fc9 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -27,6 +27,11 @@ jobs: - name: Checkout Code uses: actions/checkout@v3 + - name: Generate Build Metadata + run: | + chmod +x scripts/generate_build_metadata.sh + ./scripts/generate_build_metadata.sh + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml index 55734671..1d4a509c 100644 --- a/.github/workflows/docker-ci.yml +++ b/.github/workflows/docker-ci.yml @@ -13,6 +13,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Generate Build Metadata + run: | + chmod +x scripts/generate_build_metadata.sh + ./scripts/generate_build_metadata.sh + - name: Log in to Docker Hub uses: docker/login-action@v3 with: diff --git a/.gitignore b/.gitignore index 993d9bf3..ec9c08ba 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,7 @@ cython_debug/ # PyPI configuration file .pypirc + +# Build metadata files - generated at build time +GIT_SHA +RUNTIME_INFO diff --git a/BUILD_DATE b/BUILD_DATE index 3a98815f..a55e50a7 100644 --- a/BUILD_DATE +++ b/BUILD_DATE @@ -1 +1 @@ -2025-04-11 +2026-02-07 diff --git a/Dockerfile b/Dockerfile index b2a58aac..4005b776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,11 +18,15 @@ COPY --from=builder /usr/local/bin /usr/local/bin # Copy application code COPY ./app /app/app -COPY ./VERSION /app/VERSION COPY ./frontend /app/frontend -COPY ./BUILD_DATE /app/BUILD_DATE COPY ./LICENSE /app/LICENSE +# Copy build metadata files (generated at build time) +COPY ./VERSION /app/VERSION +COPY ./BUILD_DATE /app/BUILD_DATE +COPY ./GIT_SHA /app/GIT_SHA +COPY ./RUNTIME_INFO /app/RUNTIME_INFO + # Create runtime_info directory RUN mkdir -p /app/runtime_info diff --git a/README.md b/README.md index baa1da21..5f0134e0 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ The project includes a **UI** for uploading and managing files, and an API docum - [API Documentation](docs/API.md) - API reference - [Deployment Guide](docs/DeploymentGuide.md) - How to deploy DocuElevate - [Configuration Guide](docs/ConfigurationGuide.md) - Available configuration options +- [Build Metadata](docs/BuildMetadata.md) - Automated version and build information - [Development Guide](CONTRIBUTING.md) - How to contribute to DocuElevate - [Troubleshooting](docs/Troubleshooting.md) - Common issues and solutions diff --git a/app/config.py b/app/config.py index b9e7fc96..2b4e7dd2 100644 --- a/app/config.py +++ b/app/config.py @@ -213,6 +213,34 @@ class Settings(BaseSettings): # Default version if not found return "0.3.2-dev" + @property + def git_sha(self) -> str: + """Get Git commit SHA from environment or file.""" + # First try to get from environment + env_sha = os.environ.get("GIT_COMMIT_SHA") + if env_sha: + return env_sha + + # Then try to get from GIT_SHA file + git_sha_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "GIT_SHA") + if os.path.exists(git_sha_file): + with open(git_sha_file, "r") as f: + return f.read().strip() + + # Default if not found + return "unknown" + + @property + def runtime_info(self) -> str: + """Get runtime information from file.""" + runtime_info_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), "RUNTIME_INFO") + if os.path.exists(runtime_info_file): + with open(runtime_info_file, "r") as f: + return f.read().strip() + + # Return basic info if file not found + return f"Version: {self.version}\nBuild Date: {self.build_date}\nGit SHA: {self.git_sha}" + class Config: env_file = ".env" diff --git a/app/views/status.py b/app/views/status.py index 31b73bb4..bd566779 100644 --- a/app/views/status.py +++ b/app/views/status.py @@ -5,7 +5,6 @@ from fastapi import Request from fastapi.responses import JSONResponse from datetime import datetime import os -import subprocess import logging from app.views.base import APIRouter, templates, require_login, settings @@ -46,42 +45,26 @@ async def status_dashboard(request: Request): except Exception: container_info['id'] = 'Unknown' - # Try to get Git commit SHA from runtime info + # Get Git commit SHA from settings try: - # First check runtime info directory - if os.path.exists('/app/runtime_info/GIT_SHA'): - with open('/app/runtime_info/GIT_SHA', 'r') as f: - git_sha = f.read().strip() - # Then try environment variable - else: - git_sha = os.environ.get('GIT_COMMIT_SHA', '') - - # If still not found, try the original file location - if not git_sha and os.path.exists('/.git-commit-sha'): - with open('/.git-commit-sha', 'r') as f: - git_sha = f.read().strip() - + git_sha = settings.git_sha container_info['git_sha'] = git_sha[:7] if git_sha and git_sha != 'unknown' else 'Unknown' except Exception: container_info['git_sha'] = 'Unknown' # Try to get runtime information try: - if os.path.exists('/app/runtime_info/RUNTIME_INFO'): - with open('/app/runtime_info/RUNTIME_INFO', 'r') as f: - container_info['runtime_info'] = f.read().strip() + container_info['runtime_info'] = settings.runtime_info except Exception: pass else: container_info['is_docker'] = False - # If not in Docker, try to get Git info directly + # If not in Docker, get Git info from settings try: - git_sha = subprocess.check_output(['git', 'rev-parse', 'HEAD'], - stderr=subprocess.DEVNULL, - text=True).strip()[:7] - container_info['git_sha'] = git_sha - except (subprocess.SubprocessError, FileNotFoundError): + git_sha = settings.git_sha + container_info['git_sha'] = git_sha[:7] if git_sha and git_sha != 'unknown' else 'Unknown' + except Exception: container_info['git_sha'] = 'Unknown' except Exception: container_info = {'is_docker': False, 'id': 'Unknown', 'git_sha': 'Unknown'} diff --git a/docs/BuildMetadata.md b/docs/BuildMetadata.md new file mode 100644 index 00000000..71d40d75 --- /dev/null +++ b/docs/BuildMetadata.md @@ -0,0 +1,328 @@ +# Build Metadata Automation + +## Overview + +DocuElevate automatically generates and embeds build metadata (version, build date, Git commit SHA) into the application at build time. This metadata is displayed in the `/status` endpoint and helps track which version of the application is running in production. + +## What is Automated + +The following metadata is automatically generated and included in every build: + +1. **Build Date** - The UTC date when the build was created (format: YYYY-MM-DD) +2. **Git Commit SHA** - The short Git commit hash (7 characters) of the code being built +3. **Runtime Information** - A comprehensive summary including version, dates, commit info, and more +4. **Application Version** - Read from the `VERSION` file (manually updated for releases) + +## How It Works + +### Build Time Generation + +When the Docker image is built (locally or in CI/CD), the following happens: + +1. **GitHub Actions runs the metadata script** (`scripts/generate_build_metadata.sh`) before building the Docker image +2. **The script generates three files:** + - `BUILD_DATE` - Contains the build date in YYYY-MM-DD format + - `GIT_SHA` - Contains the short Git commit hash + - `RUNTIME_INFO` - Contains detailed build information +3. **Docker copies these files** into the image during the build process +4. **The application reads the files** at runtime via `app/config.py` properties + +### File Structure + +``` +DocuElevate/ +├── scripts/ +│ └── generate_build_metadata.sh # Script that generates metadata +├── VERSION # Manually maintained version number +├── BUILD_DATE # Generated at build time (gitignored) +├── GIT_SHA # Generated at build time (gitignored) +└── RUNTIME_INFO # Generated at build time (gitignored) +``` + +### Generated Files Format + +**BUILD_DATE:** +``` +2026-02-07 +``` + +**GIT_SHA:** +``` +6812d0d +``` + +**RUNTIME_INFO:** +``` +DocuElevate Build Information +============================== +Version: 0.4.5-dev +Build Date: 2026-02-07 +Git Commit: 6812d0d2c42a4782e78840b052f0e6da24ec8543 +Git Short SHA: 6812d0d +Git Branch: main +Commit Date: 2026-02-07T19:32:04Z +Build Timestamp: 2026-02-07T19:34:10Z +============================== +``` + +## Configuration Properties + +The `app/config.py` Settings class provides these properties for accessing build metadata: + +### `settings.version` (property) +Returns the application version with the following priority: +1. `APP_VERSION` environment variable +2. Contents of `VERSION` file +3. Default: `"0.3.2-dev"` + +### `settings.build_date` (property) +Returns the build date with the following priority: +1. `BUILD_DATE` environment variable +2. Contents of `BUILD_DATE` file +3. Default: `"Unknown build date"` + +### `settings.git_sha` (property) +Returns the Git commit SHA with the following priority: +1. `GIT_COMMIT_SHA` environment variable +2. Contents of `GIT_SHA` file +3. Default: `"unknown"` + +### `settings.runtime_info` (property) +Returns detailed runtime information: +1. Contents of `RUNTIME_INFO` file +2. Default: Basic info string with version, build date, and Git SHA + +## Usage in Application + +### In Python Code + +```python +from app.config import settings + +# Get version +version = settings.version # "0.4.5-dev" + +# Get build date +build_date = settings.build_date # "2026-02-07" + +# Get Git SHA +git_sha = settings.git_sha # "6812d0d" + +# Get full runtime info +runtime_info = settings.runtime_info # Full multi-line string +``` + +### In Templates + +The `/status` endpoint uses these properties to display metadata: + +```python +# app/views/status.py +return templates.TemplateResponse( + "status_dashboard.html", + { + "app_version": settings.version, + "build_date": settings.build_date, + "container_info": { + "git_sha": settings.git_sha[:7], + "runtime_info": settings.runtime_info, + } + } +) +``` + +## GitHub Actions Integration + +The build metadata is generated in two GitHub Actions workflows: + +### docker-build.yaml +```yaml +- name: Generate Build Metadata + run: | + chmod +x scripts/generate_build_metadata.sh + ./scripts/generate_build_metadata.sh +``` + +### docker-ci.yml +```yaml +- name: Generate Build Metadata + run: | + chmod +x scripts/generate_build_metadata.sh + ./scripts/generate_build_metadata.sh +``` + +These steps run **before** the Docker build step, ensuring the metadata files exist when Docker copies them into the image. + +## Local Development + +### Manual Generation + +To generate build metadata locally: + +```bash +# Run the script +./scripts/generate_build_metadata.sh + +# Output: +# Generating build metadata... +# ✓ BUILD_DATE: 2026-02-07 +# ✓ GIT_SHA: 6812d0d +# ✓ VERSION: 0.4.5-dev +# ✓ RUNTIME_INFO generated +``` + +### Local Docker Build + +When building Docker images locally: + +```bash +# Generate metadata first +./scripts/generate_build_metadata.sh + +# Then build the Docker image +docker build -t docuelevate:local . + +# Or use docker-compose +docker-compose build +``` + +### Testing Without Docker + +The application will still work without the generated files: + +- `BUILD_DATE` will show "Unknown build date" +- `GIT_SHA` will show "unknown" +- The app will use defaults from `app/config.py` + +## Environment Variable Override + +You can override any metadata value using environment variables: + +```bash +# Override version +export APP_VERSION="1.0.0-custom" + +# Override build date +export BUILD_DATE="2026-01-15" + +# Override Git SHA +export GIT_COMMIT_SHA="abc1234" + +# Run the application +python -m uvicorn app.main:app +``` + +This is useful for: +- Custom builds +- Testing different versions +- Development environments + +## Updating the Version + +The `VERSION` file must be **manually updated** for releases: + +```bash +# Update version for a new release +echo "0.5.0" > VERSION + +# Commit the change +git add VERSION +git commit -m "Bump version to 0.5.0" +git tag v0.5.0 +git push origin main --tags +``` + +The build metadata script will automatically include this version in all builds. + +## Troubleshooting + +### Problem: Build metadata shows "unknown" + +**Solution:** Ensure the script runs before Docker build: +```bash +./scripts/generate_build_metadata.sh +docker build -t docuelevate . +``` + +### Problem: Git SHA is "unknown" + +**Cause:** Building outside of a Git repository + +**Solution:** +- Clone the repository properly with `.git` directory +- Or set `GIT_COMMIT_SHA` environment variable + +### Problem: Build date is outdated + +**Cause:** Using cached Docker layers + +**Solution:** +```bash +# Rebuild without cache +docker build --no-cache -t docuelevate . +``` + +### Problem: Files not copied to Docker image + +**Cause:** Files listed in `.dockerignore` + +**Solution:** +- Check `.dockerignore` doesn't block `BUILD_DATE`, `GIT_SHA`, or `RUNTIME_INFO` +- The `VERSION` file should always be committed to git + +## Best Practices + +1. **Always run the script before building** - The CI/CD pipeline does this automatically +2. **Don't commit generated files** - `GIT_SHA` and `RUNTIME_INFO` are in `.gitignore` +3. **Update VERSION manually** - Only update for actual releases +4. **Use semantic versioning** - Follow `MAJOR.MINOR.PATCH` format +5. **Tag releases in Git** - Create Git tags for version releases + +## CI/CD Pipeline Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ GitHub Actions Workflow │ +├─────────────────────────────────────────────────────────────┤ +│ 1. Checkout Code │ +│ 2. Generate Build Metadata (run script) │ +│ - Creates BUILD_DATE │ +│ - Creates GIT_SHA │ +│ - Creates RUNTIME_INFO │ +│ 3. Build Docker Image │ +│ - Copies VERSION (from git) │ +│ - Copies BUILD_DATE (generated) │ +│ - Copies GIT_SHA (generated) │ +│ - Copies RUNTIME_INFO (generated) │ +│ 4. Push to Docker Hub / GHCR │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Running Container │ +├─────────────────────────────────────────────────────────────┤ +│ Application reads metadata from files in /app: │ +│ - /app/VERSION │ +│ - /app/BUILD_DATE │ +│ - /app/GIT_SHA │ +│ - /app/RUNTIME_INFO │ +│ │ +│ Displays in /status endpoint │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Future Enhancements + +Potential improvements to the build metadata system: + +1. **Automated version bumping** - Automatically increment version based on commits +2. **Changelog generation** - Auto-generate changelog from Git history +3. **Build number tracking** - Track sequential build numbers +4. **Deployment tracking** - Record when/where each build was deployed +5. **Performance metrics** - Include build time, image size, etc. + +## Related Documentation + +- [Deployment Guide](./DeploymentGuide.md) - How to deploy DocuElevate +- [Configuration Guide](./ConfigurationGuide.md) - All configuration options +- [API Documentation](./API.md) - API endpoints including `/status` diff --git a/scripts/generate_build_metadata.sh b/scripts/generate_build_metadata.sh new file mode 100755 index 00000000..e2f1af59 --- /dev/null +++ b/scripts/generate_build_metadata.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# +# Generate build metadata files for DocuElevate +# +# This script generates metadata files that are read by the application +# at runtime to display version, build date, and Git commit information +# in the /status endpoint. +# +# Files generated: +# - BUILD_DATE: UTC timestamp of when the build was created +# - GIT_SHA: Short Git commit SHA (7 characters) +# - RUNTIME_INFO: Combined build information +# +# Usage: +# ./scripts/generate_build_metadata.sh +# +# The script should be run during Docker build or CI/CD pipeline. + +set -e + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Navigate to project root +cd "${PROJECT_ROOT}" + +echo "Generating build metadata..." + +# Generate BUILD_DATE in ISO 8601 format (UTC) +BUILD_DATE=$(date -u '+%Y-%m-%d') +echo "${BUILD_DATE}" > BUILD_DATE +echo "✓ BUILD_DATE: ${BUILD_DATE}" + +# Generate GIT_SHA (short commit hash) +if git rev-parse --git-dir > /dev/null 2>&1; then + GIT_SHA=$(git rev-parse --short=7 HEAD) + echo "${GIT_SHA}" > GIT_SHA + echo "✓ GIT_SHA: ${GIT_SHA}" + + # Get full commit SHA for reference + GIT_FULL_SHA=$(git rev-parse HEAD) + + # Get commit date + GIT_COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict) + + # Get branch name (if available) + GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") +else + echo "⚠ Warning: Not a git repository, using 'unknown' for Git metadata" + echo "unknown" > GIT_SHA + GIT_SHA="unknown" + GIT_FULL_SHA="unknown" + GIT_COMMIT_DATE="unknown" + GIT_BRANCH="unknown" +fi + +# Read VERSION file +if [ -f "VERSION" ]; then + VERSION=$(cat VERSION | tr -d '\n') + echo "✓ VERSION: ${VERSION}" +else + echo "⚠ Warning: VERSION file not found" + VERSION="unknown" +fi + +# Generate RUNTIME_INFO with combined metadata +cat > RUNTIME_INFO << EOF +DocuElevate Build Information +============================== +Version: ${VERSION} +Build Date: ${BUILD_DATE} +Git Commit: ${GIT_FULL_SHA} +Git Short SHA: ${GIT_SHA} +Git Branch: ${GIT_BRANCH} +Commit Date: ${GIT_COMMIT_DATE} +Build Timestamp: $(date -u '+%Y-%m-%dT%H:%M:%SZ') +============================== +EOF + +echo "✓ RUNTIME_INFO generated" +echo "" +echo "Build metadata generation complete!" +echo "" +echo "Files created/updated:" +echo " - BUILD_DATE" +echo " - GIT_SHA" +echo " - RUNTIME_INFO" +echo "" diff --git a/tests/test_config.py b/tests/test_config.py index 2cc5ca32..a16a22bf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -80,6 +80,98 @@ class TestConfigurationValidation: assert config.session_secret is None + + +@pytest.mark.unit +class TestBuildMetadataConfiguration: + """Tests for build metadata configuration.""" + + def test_version_from_environment(self, monkeypatch): + """Test that version is read from APP_VERSION environment variable.""" + monkeypatch.setenv("APP_VERSION", "1.2.3-test") + config = Settings( + database_url="sqlite:///test.db", + redis_url="redis://localhost:6379", + openai_api_key="test", + azure_ai_key="test", + azure_region="test", + azure_endpoint="https://test.example.com", + gotenberg_url="http://localhost:3000", + workdir="/tmp", + auth_enabled=False + ) + assert config.version == "1.2.3-test" + + def test_build_date_from_environment(self, monkeypatch): + """Test that build_date is read from BUILD_DATE environment variable.""" + monkeypatch.setenv("BUILD_DATE", "2026-01-15") + config = Settings( + database_url="sqlite:///test.db", + redis_url="redis://localhost:6379", + openai_api_key="test", + azure_ai_key="test", + azure_region="test", + azure_endpoint="https://test.example.com", + gotenberg_url="http://localhost:3000", + workdir="/tmp", + auth_enabled=False + ) + assert config.build_date == "2026-01-15" + + def test_git_sha_from_environment(self, monkeypatch): + """Test that git_sha is read from GIT_COMMIT_SHA environment variable.""" + monkeypatch.setenv("GIT_COMMIT_SHA", "abc1234") + config = Settings( + database_url="sqlite:///test.db", + redis_url="redis://localhost:6379", + openai_api_key="test", + azure_ai_key="test", + azure_region="test", + azure_endpoint="https://test.example.com", + gotenberg_url="http://localhost:3000", + workdir="/tmp", + auth_enabled=False + ) + assert config.git_sha == "abc1234" + + def test_git_sha_default(self, monkeypatch, tmp_path): + """Test that git_sha defaults to 'unknown' when not set.""" + # Mock the file system to ensure no GIT_SHA file exists + import app.config + monkeypatch.setattr(app.config.os.path, 'dirname', lambda x: str(tmp_path)) + + config = Settings( + database_url="sqlite:///test.db", + redis_url="redis://localhost:6379", + openai_api_key="test", + azure_ai_key="test", + azure_region="test", + azure_endpoint="https://test.example.com", + gotenberg_url="http://localhost:3000", + workdir="/tmp", + auth_enabled=False + ) + # When no file or env var exists, should return "unknown" + assert config.git_sha == "unknown" + + def test_runtime_info_property(self): + """Test that runtime_info returns build information.""" + config = Settings( + database_url="sqlite:///test.db", + redis_url="redis://localhost:6379", + openai_api_key="test", + azure_ai_key="test", + azure_region="test", + azure_endpoint="https://test.example.com", + gotenberg_url="http://localhost:3000", + workdir="/tmp", + auth_enabled=False + ) + runtime_info = config.runtime_info + # Should contain version, build_date, and git_sha in some form + assert "Version:" in runtime_info or config.version in runtime_info + + @pytest.mark.unit class TestNotificationConfiguration: """Tests for notification configuration parsing."""