- Add Docker build workflow template with semantic versioning - Add branch protection automation script - Add deployment strategies documentation - Add Harbor registry setup guide - Update Gitea README with runner troubleshooting - Add workflow template snippets for auto-deploy Templates support: - Semantic version validation on PRs - Docker build and push to Docker Hub - Discord notifications (success/failure) - Build summaries and metadata extraction - GitHub Actions cache optimization Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
300 lines
12 KiB
Python
Executable File
300 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
Apply branch protection rules to all Gitea repositories.
|
||
|
||
This script uses the Gitea API to apply consistent branch protection rules
|
||
across all repositories owned by a user or organization.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import requests
|
||
from typing import Optional
|
||
from dataclasses import dataclass
|
||
|
||
|
||
@dataclass
|
||
class BranchProtectionConfig:
|
||
"""Configuration for branch protection rules."""
|
||
|
||
# Branch to protect
|
||
branch_name: str = "main"
|
||
|
||
# Push settings
|
||
enable_push: bool = False # Disable direct pushes (force PR workflow)
|
||
enable_push_whitelist: bool = False
|
||
push_whitelist_usernames: list[str] = None
|
||
|
||
# Pull request approval settings
|
||
required_approvals: int = 1
|
||
enable_approvals_whitelist: bool = True
|
||
approvals_whitelist_usernames: list[str] = None
|
||
dismiss_stale_approvals: bool = True
|
||
|
||
# Status check settings
|
||
enable_status_check: bool = True
|
||
status_check_contexts: list[str] = None # Empty for now, add when CI/CD is set up
|
||
|
||
# Merge settings
|
||
enable_merge_whitelist: bool = True
|
||
merge_whitelist_usernames: list[str] = None
|
||
block_on_rejected_reviews: bool = True
|
||
block_on_outdated_branch: bool = True # Critical for database migrations
|
||
|
||
# Other settings
|
||
require_signed_commits: bool = False
|
||
protected_file_patterns: str = ""
|
||
unprotected_file_patterns: str = ""
|
||
|
||
def __post_init__(self):
|
||
"""Initialize mutable default values."""
|
||
if self.push_whitelist_usernames is None:
|
||
self.push_whitelist_usernames = []
|
||
if self.approvals_whitelist_usernames is None:
|
||
self.approvals_whitelist_usernames = []
|
||
if self.merge_whitelist_usernames is None:
|
||
self.merge_whitelist_usernames = []
|
||
if self.status_check_contexts is None:
|
||
self.status_check_contexts = []
|
||
|
||
|
||
class GiteaBranchProtectionManager:
|
||
"""Manages branch protection rules across Gitea repositories."""
|
||
|
||
def __init__(self, gitea_url: str, api_token: str, owner: str):
|
||
"""
|
||
Initialize the manager.
|
||
|
||
Args:
|
||
gitea_url: Base URL of your Gitea instance (e.g., https://git.example.com)
|
||
api_token: Gitea API token with repo access
|
||
owner: Username or organization name
|
||
"""
|
||
self.gitea_url = gitea_url.rstrip('/')
|
||
self.api_token = api_token
|
||
self.owner = owner
|
||
self.headers = {
|
||
'Authorization': f'token {api_token}',
|
||
'Content-Type': 'application/json'
|
||
}
|
||
|
||
def get_repositories(self) -> list[dict]:
|
||
"""Get all repositories for the authenticated user."""
|
||
# Use /user/repos for authenticated requests (works with API token)
|
||
url = f'{self.gitea_url}/api/v1/user/repos'
|
||
params = {'limit': 100} # Adjust if you have more than 100 repos
|
||
|
||
try:
|
||
response = requests.get(url, headers=self.headers, params=params)
|
||
response.raise_for_status()
|
||
repos = response.json()
|
||
# Filter to only repos owned by the specified owner if needed
|
||
if self.owner:
|
||
repos = [r for r in repos if r.get('owner', {}).get('login') == self.owner]
|
||
return repos
|
||
except requests.exceptions.RequestException as e:
|
||
print(f"❌ Error fetching repositories: {e}")
|
||
sys.exit(1)
|
||
|
||
def get_existing_branch_protections(self, repo_name: str) -> list[dict]:
|
||
"""Get existing branch protections for a repository."""
|
||
url = f'{self.gitea_url}/api/v1/repos/{self.owner}/{repo_name}/branch_protections'
|
||
|
||
try:
|
||
response = requests.get(url, headers=self.headers)
|
||
response.raise_for_status()
|
||
return response.json()
|
||
except requests.exceptions.RequestException as e:
|
||
print(f" ⚠️ Could not fetch existing protections: {e}")
|
||
return []
|
||
|
||
def delete_branch_protection(self, repo_name: str, protection_name: str) -> bool:
|
||
"""Delete an existing branch protection rule."""
|
||
url = f'{self.gitea_url}/api/v1/repos/{self.owner}/{repo_name}/branch_protections/{protection_name}'
|
||
|
||
try:
|
||
response = requests.delete(url, headers=self.headers)
|
||
response.raise_for_status()
|
||
return True
|
||
except requests.exceptions.RequestException as e:
|
||
print(f" ⚠️ Could not delete existing protection: {e}")
|
||
return False
|
||
|
||
def apply_branch_protection(self, repo_name: str, config: BranchProtectionConfig) -> bool:
|
||
"""
|
||
Apply branch protection rules to a repository.
|
||
|
||
Args:
|
||
repo_name: Name of the repository
|
||
config: Branch protection configuration
|
||
|
||
Returns:
|
||
True if successful, False otherwise
|
||
"""
|
||
url = f'{self.gitea_url}/api/v1/repos/{self.owner}/{repo_name}/branch_protections'
|
||
|
||
payload = {
|
||
'branch_name': config.branch_name,
|
||
'enable_push': config.enable_push,
|
||
'enable_push_whitelist': config.enable_push_whitelist,
|
||
'push_whitelist_usernames': config.push_whitelist_usernames,
|
||
'required_approvals': config.required_approvals,
|
||
'enable_approvals_whitelist': config.enable_approvals_whitelist,
|
||
'approvals_whitelist_usernames': config.approvals_whitelist_usernames,
|
||
'dismiss_stale_approvals': config.dismiss_stale_approvals,
|
||
'enable_status_check': config.enable_status_check,
|
||
'status_check_contexts': config.status_check_contexts,
|
||
'enable_merge_whitelist': config.enable_merge_whitelist,
|
||
'merge_whitelist_usernames': config.merge_whitelist_usernames,
|
||
'block_on_rejected_reviews': config.block_on_rejected_reviews,
|
||
'block_on_outdated_branch': config.block_on_outdated_branch,
|
||
'require_signed_commits': config.require_signed_commits,
|
||
'protected_file_patterns': config.protected_file_patterns,
|
||
'unprotected_file_patterns': config.unprotected_file_patterns,
|
||
}
|
||
|
||
try:
|
||
response = requests.post(url, headers=self.headers, json=payload)
|
||
response.raise_for_status()
|
||
return True
|
||
except requests.exceptions.RequestException as e:
|
||
print(f" ❌ Error: {e}")
|
||
if hasattr(e.response, 'text'):
|
||
print(f" Response: {e.response.text}")
|
||
return False
|
||
|
||
def apply_to_all_repos(self, config: BranchProtectionConfig, dry_run: bool = False):
|
||
"""
|
||
Apply branch protection rules to all repositories.
|
||
|
||
Args:
|
||
config: Branch protection configuration
|
||
dry_run: If True, only show what would be done without making changes
|
||
"""
|
||
repos = self.get_repositories()
|
||
|
||
if not repos:
|
||
print("No repositories found.")
|
||
return
|
||
|
||
print(f"\n{'DRY RUN - ' if dry_run else ''}Applying branch protection to {len(repos)} repositories...\n")
|
||
|
||
success_count = 0
|
||
skip_count = 0
|
||
error_count = 0
|
||
|
||
for repo in repos:
|
||
repo_name = repo['name']
|
||
print(f"📦 {repo_name}")
|
||
|
||
# Check if branch exists
|
||
default_branch = repo.get('default_branch', 'main')
|
||
if config.branch_name != default_branch:
|
||
print(f" ℹ️ Default branch is '{default_branch}', protecting '{config.branch_name}' instead")
|
||
|
||
if dry_run:
|
||
print(f" ✓ Would apply protection to '{config.branch_name}' branch")
|
||
skip_count += 1
|
||
continue
|
||
|
||
# Check for existing protection
|
||
existing_protections = self.get_existing_branch_protections(repo_name)
|
||
existing_main_protection = None
|
||
|
||
for protection in existing_protections:
|
||
if protection.get('branch_name') == config.branch_name:
|
||
existing_main_protection = protection.get('id') or protection.get('branch_name')
|
||
break
|
||
|
||
# Delete existing protection if it exists
|
||
if existing_main_protection:
|
||
print(f" 🔄 Updating existing protection...")
|
||
self.delete_branch_protection(repo_name, existing_main_protection)
|
||
|
||
# Apply new protection
|
||
if self.apply_branch_protection(repo_name, config):
|
||
print(f" ✅ Successfully applied branch protection")
|
||
success_count += 1
|
||
else:
|
||
print(f" ❌ Failed to apply branch protection")
|
||
error_count += 1
|
||
|
||
print(f"\n{'=' * 60}")
|
||
print(f"Summary:")
|
||
print(f" ✅ Successful: {success_count}")
|
||
if dry_run:
|
||
print(f" ⏭️ Skipped (dry run): {skip_count}")
|
||
if error_count > 0:
|
||
print(f" ❌ Errors: {error_count}")
|
||
print(f"{'=' * 60}\n")
|
||
|
||
|
||
def main():
|
||
"""Main entry point."""
|
||
# Configuration - adjust these values or use environment variables
|
||
GITEA_URL = os.getenv('GITEA_URL', 'https://git.manticorum.com')
|
||
GITEA_TOKEN = os.getenv('GITEA_TOKEN')
|
||
GITEA_OWNER = os.getenv('GITEA_OWNER', 'cal')
|
||
|
||
# Check for required configuration
|
||
if not GITEA_TOKEN:
|
||
print("❌ Error: GITEA_TOKEN environment variable is required")
|
||
print("\nUsage:")
|
||
print(" export GITEA_TOKEN='your-api-token-here'")
|
||
print(" python apply_branch_protection.py [--dry-run]")
|
||
print("\nOptional environment variables:")
|
||
print(" GITEA_URL (default: https://git.manticorum.com)")
|
||
print(" GITEA_OWNER (default: cal)")
|
||
sys.exit(1)
|
||
|
||
# Parse command line arguments
|
||
dry_run = '--dry-run' in sys.argv
|
||
|
||
# Configure branch protection rules
|
||
# Adjust these settings as needed
|
||
config = BranchProtectionConfig(
|
||
branch_name="main",
|
||
enable_push=False, # Disable direct pushes
|
||
required_approvals=1,
|
||
enable_approvals_whitelist=True,
|
||
approvals_whitelist_usernames=[GITEA_OWNER],
|
||
dismiss_stale_approvals=True,
|
||
enable_status_check=True,
|
||
enable_merge_whitelist=True,
|
||
merge_whitelist_usernames=[GITEA_OWNER],
|
||
block_on_rejected_reviews=True,
|
||
block_on_outdated_branch=True, # Critical for DB repos
|
||
require_signed_commits=False,
|
||
)
|
||
|
||
# Display configuration
|
||
print(f"\n{'=' * 60}")
|
||
print(f"Gitea Branch Protection Configuration")
|
||
print(f"{'=' * 60}")
|
||
print(f"Gitea URL: {GITEA_URL}")
|
||
print(f"Owner: {GITEA_OWNER}")
|
||
print(f"Branch: {config.branch_name}")
|
||
print(f"{'=' * 60}")
|
||
print(f"Protection Rules:")
|
||
print(f" • Direct pushes: {'Disabled' if not config.enable_push else 'Enabled'}")
|
||
print(f" • Required approvals: {config.required_approvals}")
|
||
print(f" • Approvals whitelist: {', '.join(config.approvals_whitelist_usernames)}")
|
||
print(f" • Dismiss stale approvals: {config.dismiss_stale_approvals}")
|
||
print(f" • Status checks enabled: {config.enable_status_check}")
|
||
print(f" • Merge whitelist: {', '.join(config.merge_whitelist_usernames)}")
|
||
print(f" • Block on rejected reviews: {config.block_on_rejected_reviews}")
|
||
print(f" • Block on outdated branch: {config.block_on_outdated_branch}")
|
||
print(f" • Require signed commits: {config.require_signed_commits}")
|
||
print(f"{'=' * 60}\n")
|
||
|
||
if dry_run:
|
||
print("🔍 DRY RUN MODE - No changes will be made\n")
|
||
|
||
# Create manager and apply rules
|
||
manager = GiteaBranchProtectionManager(GITEA_URL, GITEA_TOKEN, GITEA_OWNER)
|
||
manager.apply_to_all_repos(config, dry_run=dry_run)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|