diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7aac2ff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,251 @@ +name: Continuous Integration + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + PYTHON_VERSION: '3.11' + +jobs: + lint-and-format: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install flake8 black isort mypy + + - name: Run black (code formatting) + run: black --check --diff app/ migrations/ scripts/ + + - name: Run isort (import sorting) + run: isort --check-only --diff app/ migrations/ scripts/ + + - name: Run flake8 (linting) + run: flake8 app/ migrations/ scripts/ --max-line-length=88 --extend-ignore=E203,W503 + + - name: Run mypy (type checking) + run: mypy app/ --ignore-missing-imports + + test-database-migrations: + runs-on: ubuntu-latest + strategy: + matrix: + db_type: [postgresql, sqlite] + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: test_password + POSTGRES_USER: test_user + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Test PostgreSQL migrations + if: matrix.db_type == 'postgresql' + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db + FLASK_APP: app.py + run: | + echo "Testing PostgreSQL migrations..." + flask db upgrade + python -c "from app import create_app, db; app = create_app(); app.app_context().push(); print('✅ PostgreSQL migration successful')" + flask db downgrade base + flask db upgrade + echo "✅ PostgreSQL migration rollback/upgrade test passed" + + - name: Test SQLite migrations + if: matrix.db_type == 'sqlite' + env: + DATABASE_URL: sqlite:///test.db + FLASK_APP: app.py + run: | + echo "Testing SQLite migrations..." + flask db upgrade + python -c "from app import create_app, db; app = create_app(); app.app_context().push(); print('✅ SQLite migration successful')" + flask db downgrade base + flask db upgrade + echo "✅ SQLite migration rollback/upgrade test passed" + + test-docker-build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Test Docker build + run: | + docker build -t timetracker-test:latest . + echo "✅ Docker build successful" + + - name: Test Docker container startup + run: | + # Start container in background + docker run -d --name test-container -p 8080:8080 \ + -e DATABASE_URL="sqlite:///test.db" \ + timetracker-test:latest + + # Wait for container to be ready + for i in {1..30}; do + if curl -f http://localhost:8080/_health >/dev/null 2>&1; then + echo "✅ Container health check passed" + break + fi + echo "Waiting for container to be ready... ($i/30)" + sleep 2 + done + + # Show container logs for debugging + docker logs test-container + + # Stop container + docker stop test-container + docker rm test-container + + security-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + + - name: Install security tools + run: | + pip install safety bandit + + - name: Run safety (dependency vulnerability scan) + run: safety check --file requirements.txt + + - name: Run bandit (security linting) + run: bandit -r app/ -f json -o bandit-report.json || true + + - name: Upload security report + uses: actions/upload-artifact@v4 + if: always() + with: + name: security-report + path: bandit-report.json + + validate-version-management: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Test version manager script + run: | + python scripts/version-manager.py status + python scripts/version-manager.py suggest + + - name: Validate version format + run: | + # Test various version formats + python -c " + import sys + sys.path.append('scripts') + from version_manager import VersionManager + vm = VersionManager() + + test_versions = ['v1.2.3', '1.2.3', 'v1.2', 'build-123', 'rc1', 'beta1', 'alpha1'] + for version in test_versions: + if vm.validate_version_format(version): + print(f'✅ {version} is valid') + else: + print(f'❌ {version} is invalid') + sys.exit(1) + " + + create-pr-preview: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + needs: [lint-and-format, test-database-migrations, test-docker-build] + steps: + - name: Comment on PR + uses: actions/github-script@v7 + with: + script: | + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const botComment = comments.find(comment => comment.user.type === 'Bot' && comment.body.includes('CI Pipeline Status')); + + const commentBody = \`## 🔍 CI Pipeline Status + + **All checks passed!** ✅ + + **Completed Checks:** + - ✅ Code formatting and linting + - ✅ Database migration tests (PostgreSQL & SQLite) + - ✅ Docker build and startup test + - ✅ Security vulnerability scan + - ✅ Version management validation + + **Ready for review and merge** 🚀 + + --- + *This comment was automatically generated by the CI pipeline.*\`; + + if (botComment) { + await github.rest.issues.updateComment({ + comment_id: botComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: commentBody, + }); + } diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2bc8297..84077e2 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -31,6 +31,8 @@ jobs: include: - name: amd64 platform: linux/amd64 + - name: arm64 + platform: linux/arm64 steps: - name: Checkout repository diff --git a/.github/workflows/migration-check.yml b/.github/workflows/migration-check.yml new file mode 100644 index 0000000..a327983 --- /dev/null +++ b/.github/workflows/migration-check.yml @@ -0,0 +1,281 @@ +name: Database Migration Validation + +on: + pull_request: + paths: + - 'app/models/**' + - 'migrations/**' + - 'requirements.txt' + push: + branches: [ main ] + paths: + - 'app/models/**' + - 'migrations/**' + +jobs: + validate-migrations: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: test_password + POSTGRES_USER: test_user + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Check for migration changes + id: migration_check + run: | + # Check if there are changes to models or migrations + if git diff --name-only HEAD~1 | grep -E "(app/models/|migrations/)" > /dev/null; then + echo "migration_changes=true" >> $GITHUB_OUTPUT + echo "📋 Migration-related changes detected" + else + echo "migration_changes=false" >> $GITHUB_OUTPUT + echo "ℹ️ No migration-related changes detected" + fi + + - name: Validate migration consistency + if: steps.migration_check.outputs.migration_changes == 'true' + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db + FLASK_APP: app.py + run: | + echo "🔍 Validating migration consistency..." + + # Initialize fresh database + flask db upgrade + + # Generate a new migration from current models + flask db migrate -m "Test migration consistency" --rev-id test_consistency + + # Check if the generated migration is empty (no changes needed) + MIGRATION_FILE=$(find migrations/versions -name "*test_consistency*.py" | head -1) + + if [ -f "$MIGRATION_FILE" ]; then + # Check if migration has actual changes + if grep -q "op\." "$MIGRATION_FILE"; then + echo "❌ Migration inconsistency detected!" + echo "The database schema doesn't match the models." + echo "Generated migration file: $MIGRATION_FILE" + cat "$MIGRATION_FILE" + exit 1 + else + echo "✅ Migration consistency validated - no schema drift detected" + # Clean up test migration + rm "$MIGRATION_FILE" + fi + else + echo "✅ No migration file generated - models are in sync" + fi + + - name: Test migration rollback safety + if: steps.migration_check.outputs.migration_changes == 'true' + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db + FLASK_APP: app.py + run: | + echo "🔄 Testing migration rollback safety..." + + # Get current migration + CURRENT_MIGRATION=$(flask db current) + echo "Current migration: $CURRENT_MIGRATION" + + if [ -n "$CURRENT_MIGRATION" ] && [ "$CURRENT_MIGRATION" != "None" ]; then + # Try to rollback one step + echo "Testing rollback..." + flask db downgrade -1 + + # Try to upgrade back + echo "Testing re-upgrade..." + flask db upgrade + + echo "✅ Migration rollback test passed" + else + echo "ℹ️ No migrations to test rollback on" + fi + + - name: Test migration with sample data + if: steps.migration_check.outputs.migration_changes == 'true' + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db + FLASK_APP: app.py + run: | + echo "📊 Testing migration with sample data..." + + # Create sample data + python -c " + from app import create_app, db + from app.models.user import User + from app.models.project import Project + import datetime + + app = create_app() + with app.app_context(): + # Create test user + user = User( + username='test_user', + email='test@example.com', + role='user' + ) + user.set_password('test_password') + db.session.add(user) + + # Create test project + project = Project( + name='Test Project', + description='Test project for migration validation', + user_id=1 + ) + db.session.add(project) + + db.session.commit() + print('✅ Sample data created successfully') + " + + # Verify data integrity after migration + python -c " + from app import create_app, db + from app.models.user import User + from app.models.project import Project + + app = create_app() + with app.app_context(): + user_count = User.query.count() + project_count = Project.query.count() + print(f'Users: {user_count}, Projects: {project_count}') + + if user_count > 0 and project_count > 0: + print('✅ Data integrity verified after migration') + else: + print('❌ Data integrity check failed') + exit(1) + " + + - name: Generate migration report + if: steps.migration_check.outputs.migration_changes == 'true' + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db + FLASK_APP: app.py + run: | + echo "📋 Generating migration report..." + + # Get migration history + echo "## Migration History" > migration_report.md + echo "" >> migration_report.md + flask db history --verbose >> migration_report.md + + # Get current schema info + echo "" >> migration_report.md + echo "## Current Schema" >> migration_report.md + echo "" >> migration_report.md + python -c " + from app import create_app, db + from sqlalchemy import inspect + + app = create_app() + with app.app_context(): + inspector = inspect(db.engine) + tables = inspector.get_table_names() + print('### Tables:') + for table in sorted(tables): + print(f'- {table}') + columns = inspector.get_columns(table) + for column in columns: + print(f' - {column[\"name\"]}: {column[\"type\"]}') + " >> migration_report.md + + cat migration_report.md + + - name: Upload migration report + if: steps.migration_check.outputs.migration_changes == 'true' + uses: actions/upload-artifact@v4 + with: + name: migration-report + path: migration_report.md + + comment-on-pr: + runs-on: ubuntu-latest + needs: validate-migrations + if: github.event_name == 'pull_request' && always() + steps: + - name: Comment migration status on PR + uses: actions/github-script@v7 + with: + script: | + const success = '${{ needs.validate-migrations.result }}' === 'success'; + const migrationChanges = '${{ needs.validate-migrations.outputs.migration_changes }}' === 'true'; + + let commentBody = '## 🗄️ Database Migration Validation\n\n'; + + if (migrationChanges) { + if (success) { + commentBody += '✅ **Migration validation passed!**\n\n'; + commentBody += '**Completed checks:**\n'; + commentBody += '- ✅ Migration consistency validation\n'; + commentBody += '- ✅ Rollback safety test\n'; + commentBody += '- ✅ Data integrity verification\n\n'; + commentBody += '**The database migrations are safe to apply.** 🚀\n'; + } else { + commentBody += '❌ **Migration validation failed!**\n\n'; + commentBody += '**Issues detected:**\n'; + commentBody += '- Migration consistency problems\n'; + commentBody += '- Rollback safety issues\n'; + commentBody += '- Data integrity concerns\n\n'; + commentBody += '**Please review the migration files and fix the issues before merging.** ⚠️\n'; + } + } else { + commentBody += 'ℹ️ **No migration-related changes detected.**\n\n'; + commentBody += 'This PR does not modify database models or migrations.\n'; + } + + commentBody += '\n---\n*This comment was automatically generated by the Migration Validation workflow.*'; + + const { data: comments } = await github.rest.issues.listComments({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Database Migration Validation') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + comment_id: botComment.id, + owner: context.repo.owner, + repo: context.repo.repo, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: commentBody, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3325a1d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,260 @@ +name: Release Management + +on: + release: + types: [published, edited] + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., v1.2.3)' + required: true + type: string + pre_release: + description: 'Mark as pre-release' + required: false + type: boolean + default: false + generate_changelog: + description: 'Auto-generate changelog' + required: false + type: boolean + default: true + +env: + REGISTRY: ghcr.io + IMAGE_NAME: drytrix/timetracker + +jobs: + validate-release: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.validate.outputs.version }} + is_prerelease: ${{ steps.validate.outputs.is_prerelease }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate release version + id: validate + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + VERSION="${{ github.event.inputs.version }}" + IS_PRERELEASE="${{ github.event.inputs.pre_release }}" + else + VERSION="${{ github.event.release.tag_name }}" + IS_PRERELEASE="${{ github.event.release.prerelease }}" + fi + + # Validate semantic version format + if [[ ! "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then + echo "❌ Invalid version format: $VERSION" + echo "Expected format: v1.2.3 or v1.2.3-alpha.1" + exit 1 + fi + + echo "✅ Version validated: $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "is_prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT + + run-tests: + runs-on: ubuntu-latest + needs: validate-release + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: test_password + POSTGRES_USER: test_user + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run database migrations test + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db + FLASK_APP: app.py + run: | + flask db upgrade + python -c "from app import create_app, db; app = create_app(); app.app_context().push(); print('✅ Database connection successful')" + + - name: Run tests + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db + run: | + if [ -d "tests" ]; then + pytest tests/ -v --cov=app --cov-report=xml + else + echo "⚠️ No tests directory found, skipping tests" + fi + + build-and-push: + runs-on: ubuntu-latest + needs: [validate-release, run-tests] + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + APP_VERSION=${{ needs.validate-release.outputs.version }} + cache-from: type=gha + cache-to: type=gha,mode=max + + generate-changelog: + runs-on: ubuntu-latest + needs: validate-release + if: github.event.inputs.generate_changelog == 'true' || github.event_name == 'release' + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate changelog + id: changelog + run: | + # Get the previous release tag + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "") + CURRENT_TAG="${{ needs.validate-release.outputs.version }}" + + if [ -n "$PREVIOUS_TAG" ]; then + echo "## Changes since $PREVIOUS_TAG" > changelog.md + echo "" >> changelog.md + + # Get commits since last tag + git log --pretty=format:"- %s (%h)" $PREVIOUS_TAG..HEAD >> changelog.md + else + echo "## Initial Release" > changelog.md + echo "" >> changelog.md + echo "- Initial release of TimeTracker" >> changelog.md + fi + + # Upload changelog as artifact + cat changelog.md + + - name: Upload changelog + uses: actions/upload-artifact@v4 + with: + name: changelog + path: changelog.md + + update-documentation: + runs-on: ubuntu-latest + needs: [validate-release, build-and-push] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Update version in documentation + run: | + VERSION="${{ needs.validate-release.outputs.version }}" + + # Update README.md with new version + if grep -q "Version:" README.md; then + sed -i "s/Version: .*/Version: $VERSION/" README.md + else + echo "Version: $VERSION" >> README.md + fi + + # Update docker-compose examples with new version + find . -name "docker-compose*.yml" -exec sed -i "s|ghcr.io/drytrix/timetracker:.*|ghcr.io/drytrix/timetracker:$VERSION|g" {} \; + + - name: Commit version updates + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add -A + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "docs: update version references to ${{ needs.validate-release.outputs.version }}" + git push + fi + + notify-deployment: + runs-on: ubuntu-latest + needs: [validate-release, build-and-push, update-documentation] + if: always() && needs.build-and-push.result == 'success' + steps: + - name: Create deployment summary + run: | + echo "# 🚀 Release Deployment Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.validate-release.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Pre-release:** ${{ needs.validate-release.outputs.is_prerelease }}" >> $GITHUB_STEP_SUMMARY + echo "**Docker Image:** \`${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate-release.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "## 📦 Deployment Commands" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Run" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "docker run -d -p 8080:8080 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate-release.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Compose" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`yaml" >> $GITHUB_STEP_SUMMARY + echo "services:" >> $GITHUB_STEP_SUMMARY + echo " app:" >> $GITHUB_STEP_SUMMARY + echo " image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate-release.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo " ports:" >> $GITHUB_STEP_SUMMARY + echo " - \"8080:8080\"" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md new file mode 100644 index 0000000..ea492cb --- /dev/null +++ b/docs/RELEASE_PROCESS.md @@ -0,0 +1,360 @@ +# TimeTracker Release Process Guide + +This document outlines the comprehensive release process for TimeTracker, including automated workflows, manual steps, and best practices. + +## 🚀 Quick Release Guide + +### Automated Release (Recommended) + +```bash +# 1. Create a complete release with changelog and GitHub release +./scripts/version-manager.sh release --version v1.2.3 --changelog --github-release + +# 2. For pre-releases +./scripts/version-manager.sh release --version v1.2.3-rc.1 --pre-release --changelog --github-release +``` + +### Manual Release Steps + +1. **Prepare Release** +2. **Create Tag** +3. **Generate Changelog** +4. **Create GitHub Release** +5. **Verify Deployment** + +## 📋 Detailed Release Process + +### 1. Pre-Release Checklist + +Before starting any release, ensure: + +- [ ] **All tests pass** in CI/CD +- [ ] **Database migrations tested** and documented +- [ ] **Docker images build** successfully +- [ ] **Documentation updated** with new features +- [ ] **Breaking changes documented** (if any) +- [ ] **Security vulnerabilities addressed** +- [ ] **Performance regressions checked** + +### 2. Release Preparation + +#### Check Current Status +```bash +# Check current version and status +./scripts/version-manager.sh status + +# Check for uncommitted changes +git status + +# Ensure you're on main branch +git checkout main +git pull origin main +``` + +#### Version Selection +Follow [Semantic Versioning](https://semver.org/): + +- **Major** (v2.0.0): Breaking changes, major new features +- **Minor** (v1.1.0): New features, backward compatible +- **Patch** (v1.0.1): Bug fixes, backward compatible +- **Pre-release** (v1.0.0-rc.1): Release candidates, beta versions + +#### Suggested Version +```bash +# Get version suggestion +./scripts/version-manager.sh suggest +``` + +### 3. Release Types + +#### 3.1 Standard Release + +```bash +# Create standard release +./scripts/version-manager.sh release \ + --version v1.2.3 \ + --message "Release 1.2.3 with new features and bug fixes" \ + --changelog \ + --github-release +``` + +**What this does:** +1. Creates and pushes git tag +2. Generates changelog from commits +3. Creates GitHub release with changelog +4. Triggers Docker image build via GitHub Actions + +#### 3.2 Pre-Release + +```bash +# Create pre-release (RC, beta, alpha) +./scripts/version-manager.sh release \ + --version v1.2.3-rc.1 \ + --message "Release candidate for 1.2.3" \ + --pre-release \ + --changelog \ + --github-release +``` + +#### 3.3 Hotfix Release + +```bash +# Create hotfix from main branch +git checkout main +git pull origin main + +# Apply hotfix +git cherry-pick + +# Create hotfix release +./scripts/version-manager.sh release \ + --version v1.2.4 \ + --message "Hotfix: Critical security update" \ + --changelog \ + --github-release +``` + +### 4. Manual Release Steps + +If you prefer manual control over the release process: + +#### Step 1: Create Tag +```bash +# Create annotated tag +git tag -a v1.2.3 -m "Release 1.2.3" +git push origin v1.2.3 +``` + +#### Step 2: Generate Changelog +```bash +# Generate changelog +python scripts/generate-changelog.py v1.2.3 --output CHANGELOG.md + +# Review and edit changelog if needed +nano CHANGELOG.md +``` + +#### Step 3: Create GitHub Release +```bash +# Using GitHub CLI +gh release create v1.2.3 \ + --title "TimeTracker v1.2.3" \ + --notes-file CHANGELOG.md + +# Or via GitHub web interface +# Go to: https://github.com/your-repo/releases/new +``` + +### 5. Post-Release Verification + +#### 5.1 Verify GitHub Actions +- Check that Docker build workflow completed successfully +- Verify Docker images are published to GHCR +- Confirm all CI/CD checks passed + +#### 5.2 Test Docker Images +```bash +# Test the released image +docker run -d --name test-release -p 8080:8080 \ + ghcr.io/drytrix/timetracker:v1.2.3 + +# Verify health +curl -f http://localhost:8080/_health + +# Clean up +docker stop test-release && docker rm test-release +``` + +#### 5.3 Update Documentation +- [ ] Update README.md version references +- [ ] Update deployment documentation +- [ ] Update Docker Compose examples +- [ ] Notify users of new release + +### 6. Release Workflow Automation + +The release process triggers several automated workflows: + +#### 6.1 Release Workflow (`release.yml`) +**Triggered by:** GitHub release creation or manual dispatch + +**Steps:** +1. **Validate Release** - Ensures version format is correct +2. **Run Tests** - Full test suite with database migrations +3. **Build & Push Docker** - Multi-architecture Docker images +4. **Generate Changelog** - Automated changelog generation +5. **Update Documentation** - Version references in docs +6. **Notify Deployment** - Summary and deployment instructions + +#### 6.2 CI Workflow (`ci.yml`) +**Triggered by:** Push to main/develop, pull requests + +**Steps:** +1. **Lint & Format** - Code quality checks +2. **Test Database Migrations** - PostgreSQL & SQLite testing +3. **Test Docker Build** - Container build and startup verification +4. **Security Scan** - Dependency and code security scanning +5. **Version Management Validation** - Version manager script testing + +#### 6.3 Migration Check Workflow (`migration-check.yml`) +**Triggered by:** Changes to models or migrations + +**Steps:** +1. **Validate Migrations** - Schema consistency and rollback safety +2. **Test with Sample Data** - Data integrity verification +3. **Generate Migration Report** - Detailed migration analysis + +### 7. Emergency Procedures + +#### 7.1 Rollback Release +```bash +# Delete tag locally and remotely +git tag -d v1.2.3 +git push origin --delete v1.2.3 + +# Delete GitHub release +gh release delete v1.2.3 + +# Revert commits if needed +git revert +``` + +#### 7.2 Fix Broken Release +```bash +# Create hotfix +git checkout v1.2.3 +git cherry-pick + +# Create new patch release +./scripts/version-manager.sh release \ + --version v1.2.4 \ + --message "Hotfix for v1.2.3 issues" \ + --changelog \ + --github-release +``` + +### 8. Release Schedule + +#### Recommended Schedule +- **Major releases**: Every 6-12 months +- **Minor releases**: Every 1-2 months +- **Patch releases**: As needed for critical fixes +- **Pre-releases**: 1-2 weeks before major/minor releases + +#### Release Windows +- **Regular releases**: Tuesday-Thursday (better for issue resolution) +- **Hotfixes**: Any day (emergency only) +- **Pre-releases**: Friday (allows weekend testing) + +### 9. Communication + +#### Internal Team +- [ ] Notify team before release +- [ ] Share release notes +- [ ] Coordinate deployment timing +- [ ] Plan post-release monitoring + +#### External Users +- [ ] Update release notes on GitHub +- [ ] Update documentation website +- [ ] Notify via social media/newsletters +- [ ] Update Docker Hub descriptions + +### 10. Quality Gates + +Every release must pass: + +- [ ] **All automated tests** (unit, integration, E2E) +- [ ] **Database migration tests** (up and down) +- [ ] **Docker build verification** (multi-architecture) +- [ ] **Security scans** (dependencies and code) +- [ ] **Performance benchmarks** (no significant regression) +- [ ] **Documentation review** (accuracy and completeness) + +### 11. Troubleshooting + +#### Common Issues + +**Issue**: Docker build fails +```bash +# Check Docker build locally +docker build -t test-build . + +# Check workflow logs in GitHub Actions +``` + +**Issue**: Migration validation fails +```bash +# Test migrations locally +flask db upgrade +flask db downgrade +flask db upgrade +``` + +**Issue**: Version tag already exists +```bash +# Check existing tags +git tag -l + +# Delete if needed +git tag -d v1.2.3 +git push origin --delete v1.2.3 +``` + +### 12. Tools and Dependencies + +#### Required Tools +- **Git** - Version control +- **GitHub CLI** (`gh`) - GitHub release management +- **Docker** - Container testing +- **Python 3.11+** - Script execution +- **Flask** - Database migration testing + +#### Installation +```bash +# Install GitHub CLI +# macOS: brew install gh +# Ubuntu: sudo apt install gh +# Windows: winget install GitHub.CLI + +# Authenticate +gh auth login + +# Install Python dependencies +pip install -r requirements.txt +``` + +### 13. Metrics and Monitoring + +Track release metrics: +- **Release frequency** - How often releases are made +- **Lead time** - Time from commit to release +- **Failure rate** - Percentage of failed releases +- **Recovery time** - Time to fix broken releases +- **User adoption** - Docker pull statistics + +### 14. Continuous Improvement + +Regular review of: +- [ ] Release process efficiency +- [ ] Automation opportunities +- [ ] Quality gate effectiveness +- [ ] User feedback incorporation +- [ ] Tool and workflow updates + +--- + +## 🔗 Related Documentation + +- [Version Management System](VERSION_MANAGEMENT.md) +- [Database Migrations](../migrations/README.md) +- [Docker Setup](DOCKER_PUBLIC_SETUP.md) +- [Contributing Guidelines](CONTRIBUTING.md) + +## 🆘 Support + +For release process issues: +1. Check this documentation +2. Review GitHub Actions logs +3. Test locally with provided commands +4. Create issue with detailed error information diff --git a/scripts/generate-changelog.py b/scripts/generate-changelog.py new file mode 100644 index 0000000..7e78a0e --- /dev/null +++ b/scripts/generate-changelog.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +""" +Automated Changelog Generator for TimeTracker +Generates changelogs from git commits and GitHub issues/PRs +""" + +import os +import sys +import subprocess +import argparse +import re +from datetime import datetime +from typing import List, Dict, Tuple, Optional +import requests + +class ChangelogGenerator: + def __init__(self, repo_path: str = "."): + self.repo_path = repo_path + self.github_token = os.getenv('GITHUB_TOKEN') + self.repo_url = self._get_repo_url() + + def _get_repo_url(self) -> Optional[str]: + """Get GitHub repository URL from git remote""" + try: + result = subprocess.run( + ['git', 'remote', 'get-url', 'origin'], + cwd=self.repo_path, + capture_output=True, + text=True + ) + if result.returncode == 0: + url = result.stdout.strip() + # Convert SSH URL to HTTPS if needed + if url.startswith('git@github.com:'): + url = url.replace('git@github.com:', 'https://github.com/') + if url.endswith('.git'): + url = url[:-4] + return url + except Exception as e: + print(f"Warning: Could not get repository URL: {e}") + return None + + def _run_git_command(self, command: List[str]) -> str: + """Run a git command and return output""" + try: + result = subprocess.run( + ['git'] + command, + cwd=self.repo_path, + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"Git command failed: {e}") + return "" + + def get_latest_tag(self) -> str: + """Get the latest git tag""" + output = self._run_git_command(['describe', '--tags', '--abbrev=0']) + return output if output else "HEAD~50" # Fallback to last 50 commits + + def get_commits_since_tag(self, since_tag: str) -> List[Dict[str, str]]: + """Get commits since the specified tag""" + if since_tag == "HEAD~50": + command = ['log', '--pretty=format:%H|%s|%an|%ad|%b', '--date=short', '-50'] + else: + command = ['log', f'{since_tag}..HEAD', '--pretty=format:%H|%s|%an|%ad|%b', '--date=short'] + + output = self._run_git_command(command) + commits = [] + + if output: + for line in output.split('\n'): + if '|' in line: + parts = line.split('|', 4) + if len(parts) >= 4: + commits.append({ + 'hash': parts[0][:8], + 'subject': parts[1], + 'author': parts[2], + 'date': parts[3], + 'body': parts[4] if len(parts) > 4 else '' + }) + + return commits + + def categorize_commits(self, commits: List[Dict[str, str]]) -> Dict[str, List[Dict[str, str]]]: + """Categorize commits by type""" + categories = { + 'Features': [], + 'Bug Fixes': [], + 'Improvements': [], + 'Documentation': [], + 'Refactoring': [], + 'Dependencies': [], + 'Database': [], + 'Docker': [], + 'CI/CD': [], + 'Other': [] + } + + # Patterns for categorization + patterns = { + 'Features': [r'^feat(\(.+\))?:', r'^add:', r'^implement:', r'^new:'], + 'Bug Fixes': [r'^fix(\(.+\))?:', r'^bug:', r'^hotfix:', r'^patch:'], + 'Improvements': [r'^improve:', r'^enhance:', r'^update:', r'^upgrade:'], + 'Documentation': [r'^docs?(\(.+\))?:', r'^readme:', r'^doc:'], + 'Refactoring': [r'^refactor(\(.+\))?:', r'^cleanup:', r'^reorganize:'], + 'Dependencies': [r'^deps?(\(.+\))?:', r'^bump:', r'^requirements:'], + 'Database': [r'^db:', r'^migration:', r'^schema:', r'^alembic:'], + 'Docker': [r'^docker:', r'^dockerfile:', r'^compose:'], + 'CI/CD': [r'^ci:', r'^cd:', r'^workflow:', r'^action:', r'^build:'] + } + + for commit in commits: + subject = commit['subject'].lower() + categorized = False + + for category, category_patterns in patterns.items(): + for pattern in category_patterns: + if re.match(pattern, subject): + categories[category].append(commit) + categorized = True + break + if categorized: + break + + if not categorized: + categories['Other'].append(commit) + + return categories + + def extract_breaking_changes(self, commits: List[Dict[str, str]]) -> List[Dict[str, str]]: + """Extract breaking changes from commits""" + breaking_changes = [] + + for commit in commits: + # Look for BREAKING CHANGE in commit message + full_message = f"{commit['subject']} {commit['body']}" + if 'BREAKING CHANGE' in full_message or 'breaking:' in commit['subject'].lower(): + breaking_changes.append(commit) + + return breaking_changes + + def get_github_prs_and_issues(self, commits: List[Dict[str, str]]) -> Dict[str, List[Dict]]: + """Get GitHub PRs and issues mentioned in commits""" + prs = [] + issues = [] + + if not self.github_token or not self.repo_url: + return {'prs': prs, 'issues': issues} + + # Extract PR/issue numbers from commit messages + pr_pattern = r'#(\d+)' + mentioned_numbers = set() + + for commit in commits: + matches = re.findall(pr_pattern, f"{commit['subject']} {commit['body']}") + mentioned_numbers.update(matches) + + # Fetch details from GitHub API + repo_parts = self.repo_url.replace('https://github.com/', '').split('/') + if len(repo_parts) >= 2: + owner, repo = repo_parts[0], repo_parts[1] + + headers = {'Authorization': f'token {self.github_token}'} + + for number in mentioned_numbers: + try: + # Try to fetch as PR first + pr_url = f'https://api.github.com/repos/{owner}/{repo}/pulls/{number}' + response = requests.get(pr_url, headers=headers) + + if response.status_code == 200: + pr_data = response.json() + prs.append({ + 'number': number, + 'title': pr_data['title'], + 'url': pr_data['html_url'], + 'author': pr_data['user']['login'] + }) + else: + # Try as issue + issue_url = f'https://api.github.com/repos/{owner}/{repo}/issues/{number}' + response = requests.get(issue_url, headers=headers) + + if response.status_code == 200: + issue_data = response.json() + issues.append({ + 'number': number, + 'title': issue_data['title'], + 'url': issue_data['html_url'], + 'author': issue_data['user']['login'] + }) + + except Exception as e: + print(f"Warning: Could not fetch GitHub data for #{number}: {e}") + + return {'prs': prs, 'issues': issues} + + def generate_changelog(self, version: str, since_tag: str = None) -> str: + """Generate complete changelog""" + if not since_tag: + since_tag = self.get_latest_tag() + + print(f"Generating changelog for version {version} since {since_tag}...") + + # Get commits + commits = self.get_commits_since_tag(since_tag) + print(f"Found {len(commits)} commits") + + if not commits: + return f"# {version}\n\n*No changes since {since_tag}*\n" + + # Categorize commits + categorized = self.categorize_commits(commits) + + # Extract breaking changes + breaking_changes = self.extract_breaking_changes(commits) + + # Get GitHub data + github_data = self.get_github_prs_and_issues(commits) + + # Generate changelog content + changelog = self._format_changelog( + version, since_tag, categorized, breaking_changes, github_data + ) + + return changelog + + def _format_changelog( + self, + version: str, + since_tag: str, + categorized: Dict[str, List[Dict[str, str]]], + breaking_changes: List[Dict[str, str]], + github_data: Dict[str, List[Dict]] + ) -> str: + """Format the changelog content""" + + changelog = f"# {version}\n\n" + changelog += f"*Released on {datetime.now().strftime('%Y-%m-%d')}*\n\n" + + # Summary + total_commits = sum(len(commits) for commits in categorized.values()) + changelog += f"**{total_commits} changes** since {since_tag}\n\n" + + # Breaking changes (if any) + if breaking_changes: + changelog += "## ⚠️ Breaking Changes\n\n" + for commit in breaking_changes: + changelog += f"- {commit['subject']} ([{commit['hash']}]" + if self.repo_url: + changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" + else: + changelog += ")\n" + changelog += "\n" + + # Features and improvements + feature_categories = ['Features', 'Improvements', 'Bug Fixes'] + for category in feature_categories: + if categorized[category]: + icon = {'Features': '✨', 'Improvements': '🚀', 'Bug Fixes': '🐛'}[category] + changelog += f"## {icon} {category}\n\n" + + for commit in categorized[category]: + # Clean up commit subject + subject = re.sub(r'^(feat|fix|improve|enhance|update)(\(.+\))?:\s*', '', commit['subject'], flags=re.IGNORECASE) + changelog += f"- {subject} ([{commit['hash']}]" + if self.repo_url: + changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" + else: + changelog += ")\n" + changelog += "\n" + + # Technical changes + tech_categories = ['Database', 'Docker', 'CI/CD', 'Refactoring', 'Dependencies'] + tech_changes = any(categorized[cat] for cat in tech_categories) + + if tech_changes: + changelog += "## 🔧 Technical Changes\n\n" + for category in tech_categories: + if categorized[category]: + changelog += f"### {category}\n" + for commit in categorized[category]: + subject = re.sub(r'^(db|docker|ci|cd|refactor|deps?)(\(.+\))?:\s*', '', commit['subject'], flags=re.IGNORECASE) + changelog += f"- {subject} ([{commit['hash']}]" + if self.repo_url: + changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" + else: + changelog += ")\n" + changelog += "\n" + + # Documentation + if categorized['Documentation']: + changelog += "## 📚 Documentation\n\n" + for commit in categorized['Documentation']: + subject = re.sub(r'^docs?(\(.+\))?:\s*', '', commit['subject'], flags=re.IGNORECASE) + changelog += f"- {subject} ([{commit['hash']}]" + if self.repo_url: + changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" + else: + changelog += ")\n" + changelog += "\n" + + # Other changes + if categorized['Other']: + changelog += "## 📋 Other Changes\n\n" + for commit in categorized['Other']: + changelog += f"- {commit['subject']} ([{commit['hash']}]" + if self.repo_url: + changelog += f"({self.repo_url}/commit/{commit['hash']}))\n" + else: + changelog += ")\n" + changelog += "\n" + + # GitHub PRs and Issues + if github_data['prs'] or github_data['issues']: + changelog += "## 🔗 Related\n\n" + + if github_data['prs']: + changelog += "**Pull Requests:**\n" + for pr in github_data['prs']: + changelog += f"- [{pr['title']}]({pr['url']}) by @{pr['author']}\n" + changelog += "\n" + + if github_data['issues']: + changelog += "**Issues:**\n" + for issue in github_data['issues']: + changelog += f"- [{issue['title']}]({issue['url']}) by @{issue['author']}\n" + changelog += "\n" + + # Contributors + contributors = set() + for category_commits in categorized.values(): + for commit in category_commits: + contributors.add(commit['author']) + + if contributors: + changelog += "## 👥 Contributors\n\n" + changelog += f"Thanks to all contributors: {', '.join(f'@{c}' for c in sorted(contributors))}\n\n" + + return changelog + +def main(): + parser = argparse.ArgumentParser(description='Generate changelog for TimeTracker') + parser.add_argument('version', help='Version for the changelog (e.g., v1.2.3)') + parser.add_argument('--since', help='Generate changelog since this tag/commit') + parser.add_argument('--output', '-o', help='Output file (default: CHANGELOG.md)') + parser.add_argument('--append', action='store_true', help='Append to existing changelog') + parser.add_argument('--repo-path', default='.', help='Repository path') + + args = parser.parse_args() + + generator = ChangelogGenerator(args.repo_path) + changelog = generator.generate_changelog(args.version, args.since) + + if args.output: + output_file = args.output + else: + output_file = os.path.join(args.repo_path, 'CHANGELOG.md') + + # Write changelog + mode = 'a' if args.append else 'w' + with open(output_file, mode, encoding='utf-8') as f: + if args.append and os.path.exists(output_file): + f.write('\n\n---\n\n') + f.write(changelog) + + print(f"Changelog written to {output_file}") + + # Also output to stdout for GitHub Actions + print("\n" + "="*50) + print("GENERATED CHANGELOG:") + print("="*50) + print(changelog) + +if __name__ == '__main__': + main() diff --git a/scripts/version-manager.py b/scripts/version-manager.py index 2cfda8f..ff56a71 100644 --- a/scripts/version-manager.py +++ b/scripts/version-manager.py @@ -199,13 +199,16 @@ class VersionManager: def main(): parser = argparse.ArgumentParser(description='Version Manager for TimeTracker') - parser.add_argument('action', choices=['tag', 'build', 'list', 'info', 'status', 'suggest'], + parser.add_argument('action', choices=['tag', 'build', 'list', 'info', 'status', 'suggest', 'release', 'changelog'], help='Action to perform') parser.add_argument('--version', '-v', help='Version string (e.g., v1.2.3, build-123)') parser.add_argument('--message', '-m', help='Tag message') parser.add_argument('--build-number', '-b', type=int, help='Build number for build tags') parser.add_argument('--no-push', action='store_true', help='Don\'t push tag to remote') parser.add_argument('--tag', '-t', help='Tag to show info for (for info action)') + parser.add_argument('--pre-release', action='store_true', help='Mark as pre-release') + parser.add_argument('--changelog', action='store_true', help='Generate changelog') + parser.add_argument('--github-release', action='store_true', help='Create GitHub release') args = parser.parse_args() @@ -240,6 +243,57 @@ def main(): else: print("No current version found") print("Suggested first version: v1.0.0") + + elif args.action == 'release': + if not args.version: + print("Error: Version required for release action") + print("Use --version or -v to specify version") + sys.exit(1) + + print(f"🚀 Creating release {args.version}...") + + # Create tag + if vm.create_tag(args.version, args.message, push=not args.no_push): + print(f"✅ Tag {args.version} created successfully") + + # Generate changelog if requested + if args.changelog: + print("📋 Generating changelog...") + changelog_cmd = f"python scripts/generate-changelog.py {args.version}" + if vm.run_command(changelog_cmd, capture_output=False): + print("✅ Changelog generated successfully") + else: + print("⚠️ Changelog generation failed") + + # Create GitHub release if requested + if args.github_release: + print("🐙 Creating GitHub release...") + github_cmd = f"gh release create {args.version}" + if args.pre_release: + github_cmd += " --prerelease" + if args.changelog and os.path.exists("CHANGELOG.md"): + github_cmd += " --notes-file CHANGELOG.md" + elif args.message: + github_cmd += f" --notes '{args.message}'" + + if vm.run_command(github_cmd, capture_output=False): + print("✅ GitHub release created successfully") + else: + print("⚠️ GitHub release creation failed (make sure 'gh' CLI is installed and authenticated)") + else: + print("❌ Failed to create tag") + sys.exit(1) + + elif args.action == 'changelog': + current_tag = vm.get_latest_tag() + version = args.version or vm.suggest_next_version(current_tag) + + print(f"📋 Generating changelog for {version}...") + changelog_cmd = f"python scripts/generate-changelog.py {version}" + if vm.run_command(changelog_cmd, capture_output=False): + print("✅ Changelog generated successfully") + else: + print("❌ Changelog generation failed") if __name__ == '__main__': main()