3 Commits
Author SHA1 Message Date
jpmschweitzerandClaude Opus 4.5 424c94d79a feat: Add Gitea release cleanup executor
Build and Push / build (release) Successful in 27s
- Add gitea_release_cleanup_executor for automated release cleanup
- Add GITEA_TOKEN setting for API token authentication
- Configurable retention count, repo exclusions, and dry-run mode
- Designed to run daily before Watchtower (3 AM)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 13:25:02 +01:00
jpmschweitzerandClaude Opus 4.5 8f1a4402e7 fix(ci): Correct Watchtower port
Build and Push / build (release) Successful in 28s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:57:08 +01:00
jpmschweitzerandClaude Opus 4.5 9ae10af1df ci: Add Watchtower update trigger after build
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:45:06 +01:00
5 changed files with 240 additions and 1 deletions
+6
View File
@@ -25,3 +25,9 @@ jobs:
tags: |
git.schweitz.internal/jpmschweitzer/scheduler:latest
git.schweitz.internal/jpmschweitzer/scheduler:${{ github.ref_name }}
- name: Trigger Watchtower update
if: success()
run: |
curl -sf -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
http://watchtower:8080/v1/update
+21
View File
@@ -4,6 +4,27 @@ All notable changes to The Scheduler will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.1.0] - 2025-12-14
### Added
- **Gitea Release Cleanup Executor** (`gitea_release_cleanup_executor.py`)
- Automatically cleans up old releases across all Gitea repositories
- Configurable retention count (default: 5 releases per repo)
- Repository exclusion list support
- Dry-run mode for safe testing
- Designed to run before Watchtower to prevent image tag accumulation
- **GITEA_TOKEN setting** in config for API token authentication (separate from password)
## [1.0.4] - 2025-12-14
### Fixed
- CI/CD: Correct Watchtower port (8080)
## [1.0.3] - 2025-12-14
### Added
- CI/CD: Trigger Watchtower update after successful Docker build
## [1.0.2] - 2025-12-14
### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "the-scheduler"
version = "1.0.2"
version = "1.1.0"
description = "System-wide maintenance orchestration - backups, doc mirroring, cleanup, task automation"
readme = "README.md"
requires-python = ">=3.12"
+1
View File
@@ -57,6 +57,7 @@ class Settings(BaseSettings):
gitea_url: str = Field(default="http://gitea:3000", alias="GITEA_URL")
gitea_user: str = Field(default="library", alias="GITEA_USER")
gitea_password: str = Field(default="", alias="GITEA_PASSWORD")
gitea_token: str = Field(default="", alias="GITEA_TOKEN") # API token with write:repository scope
gitea_ssh_host: str = Field(default="gitea", alias="GITEA_SSH_HOST")
gitea_ssh_port: int = Field(default=22, alias="GITEA_SSH_PORT")
@@ -0,0 +1,211 @@
"""
Gitea Release Cleanup Executor
Cleans up old releases across all Gitea repositories, keeping only the most recent
releases per repository. Designed to run before Watchtower to prevent accumulation
of old container image tags.
Config schema:
{
"keep_count": 5, # Number of releases to keep per repo (default: 5)
"exclude_repos": [], # Repository names to skip (default: [])
"dry_run": false # If true, only log what would be deleted (default: false)
}
Example config:
{
"keep_count": 5,
"exclude_repos": ["important-repo", "legacy-app"],
"dry_run": false
}
Required settings:
- GITEA_URL: Base URL of Gitea instance
- GITEA_TOKEN: API token with write:repository scope
"""
import logging
from typing import Any
import httpx
from src.config import Settings
logger = logging.getLogger(__name__)
# Default configuration values
DEFAULT_KEEP_COUNT = 5
DEFAULT_TIMEOUT = 60
API_BASE = "/api/v1"
async def execute(config: dict, settings: Settings) -> str:
"""
Clean up old releases across all accessible Gitea repositories.
Args:
config: Task configuration (see module docstring)
settings: Global scheduler settings
Returns:
Summary of cleanup actions taken
Raises:
ValueError: On configuration error
Exception: On API call failure
"""
# Validate settings
if not settings.gitea_url:
raise ValueError("GITEA_URL not configured")
if not settings.gitea_token:
raise ValueError("GITEA_TOKEN not configured - required for release deletion")
# Parse configuration
keep_count = config.get("keep_count", DEFAULT_KEEP_COUNT)
exclude_repos = config.get("exclude_repos", [])
dry_run = config.get("dry_run", False)
if keep_count < 1:
raise ValueError(f"keep_count must be at least 1, got {keep_count}")
base_url = settings.gitea_url.rstrip("/")
headers = {"Authorization": f"token {settings.gitea_token}"}
stats = {
"repos_scanned": 0,
"repos_with_releases": 0,
"releases_deleted": 0,
"releases_skipped": 0,
"errors": [],
}
mode = "DRY RUN" if dry_run else "LIVE"
logger.info(f"Starting Gitea release cleanup ({mode}): keeping {keep_count} releases per repo")
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
# Fetch all repositories
repos = await _fetch_user_repos(client, base_url, headers)
logger.info(f"Found {len(repos)} repositories")
for repo in repos:
owner = repo["owner"]["login"]
name = repo["name"]
full_name = f"{owner}/{name}"
# Check exclusion list
if name in exclude_repos or full_name in exclude_repos:
logger.debug(f"Skipping excluded repo: {full_name}")
continue
stats["repos_scanned"] += 1
try:
# Fetch releases for this repo
releases = await _fetch_releases(client, base_url, headers, owner, name)
if not releases:
continue
stats["repos_with_releases"] += 1
# Determine which releases to delete (beyond keep_count)
to_delete = releases[keep_count:]
if not to_delete:
logger.debug(f"{full_name}: {len(releases)} releases, nothing to delete")
continue
logger.info(f"{full_name}: {len(releases)} releases, deleting {len(to_delete)}")
# Delete old releases
for release in to_delete:
release_id = release["id"]
tag_name = release["tag_name"]
if dry_run:
logger.info(f" [DRY RUN] Would delete: {tag_name} (id={release_id})")
stats["releases_skipped"] += 1
else:
try:
await _delete_release(client, base_url, headers, owner, name, release_id)
logger.info(f" Deleted: {tag_name}")
stats["releases_deleted"] += 1
except Exception as e:
error_msg = f"{full_name}/{tag_name}: {e}"
logger.warning(f" Failed to delete {tag_name}: {e}")
stats["errors"].append(error_msg)
except Exception as e:
error_msg = f"{full_name}: {e}"
logger.error(f"Error processing {full_name}: {e}")
stats["errors"].append(error_msg)
# Build summary
summary = _build_summary(stats, dry_run)
logger.info(f"Cleanup complete: {summary}")
return summary
async def _fetch_user_repos(
client: httpx.AsyncClient,
base_url: str,
headers: dict,
) -> list[dict[str, Any]]:
"""Fetch all repositories accessible to the authenticated user."""
url = f"{base_url}{API_BASE}/user/repos"
params = {"limit": 100}
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json()
async def _fetch_releases(
client: httpx.AsyncClient,
base_url: str,
headers: dict,
owner: str,
repo: str,
) -> list[dict[str, Any]]:
"""Fetch releases for a repository, sorted newest first (Gitea default)."""
url = f"{base_url}{API_BASE}/repos/{owner}/{repo}/releases"
params = {"limit": 100}
response = await client.get(url, headers=headers, params=params)
response.raise_for_status()
return response.json()
async def _delete_release(
client: httpx.AsyncClient,
base_url: str,
headers: dict,
owner: str,
repo: str,
release_id: int,
) -> None:
"""Delete a specific release."""
url = f"{base_url}{API_BASE}/repos/{owner}/{repo}/releases/{release_id}"
response = await client.delete(url, headers=headers)
response.raise_for_status()
def _build_summary(stats: dict, dry_run: bool) -> str:
"""Build a human-readable summary of the cleanup operation."""
parts = [
f"Scanned {stats['repos_scanned']} repos",
f"{stats['repos_with_releases']} with releases",
]
if dry_run:
parts.append(f"{stats['releases_skipped']} releases would be deleted")
else:
parts.append(f"{stats['releases_deleted']} releases deleted")
if stats["errors"]:
parts.append(f"{len(stats['errors'])} errors")
return ", ".join(parts)