Contexto
ulfblk-ci-github genera archivos YAML de GitHub Actions programaticamente. En un monorepo con 28 paquetes, mantener workflows a mano es insostenible: cambiar la version de Python en un job requiere editarlo en N archivos, un typo en un runner name pasa desapercibido hasta que falla en CI. Necesitabamos tratar los workflows como codigo generado, no como config editada a mano.
Lo que aprendi
Un workflow builder en Python que genera YAML valido y un sistema de validadores que detectan errores comunes antes de hacer commit.
Modelo del workflow
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Step:
name: str
uses: str | None = None
run: str | None = None
with_params: dict[str, str] = field(default_factory=dict)
env: dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
step: dict[str, Any] = {"name": self.name}
if self.uses:
step["uses"] = self.uses
if self.run:
step["run"] = self.run
if self.with_params:
step["with"] = self.with_params
if self.env:
step["env"] = self.env
return step
@dataclass
class Job:
name: str
runs_on: str = "ubuntu-latest"
steps: list[Step] = field(default_factory=list)
needs: list[str] = field(default_factory=list)
strategy: dict[str, Any] | None = None
permissions: dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
job: dict[str, Any] = {
"name": self.name,
"runs-on": self.runs_on,
"steps": [s.to_dict() for s in self.steps],
}
if self.needs:
job["needs"] = self.needs
if self.strategy:
job["strategy"] = self.strategy
if self.permissions:
job["permissions"] = self.permissions
return job
@dataclass
class Workflow:
name: str
on: dict[str, Any]
jobs: dict[str, Job] = field(default_factory=dict)
permissions: dict[str, str] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
wf: dict[str, Any] = {
"name": self.name,
"on": self.on,
}
if self.permissions:
wf["permissions"] = self.permissions
wf["jobs"] = {k: v.to_dict() for k, v in self.jobs.items()}
return wf
Builder con helpers comunes
import yaml
from pathlib import Path
class WorkflowBuilder:
"""Builder para generar workflows de GitHub Actions."""
def __init__(self, name: str):
self.workflow = Workflow(name=name, on={})
def on_push(self, branches: list[str], paths: list[str] | None = None):
trigger: dict[str, Any] = {"branches": branches}
if paths:
trigger["paths"] = paths
self.workflow.on["push"] = trigger
return self
def on_pull_request(self, branches: list[str]):
self.workflow.on["pull_request"] = {"branches": branches}
return self
def add_job(self, job_id: str, job: Job):
self.workflow.jobs[job_id] = job
return self
def build(self) -> dict:
return self.workflow.to_dict()
def write(self, output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
yaml.dump(
self.build(),
f,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
def checkout_step(fetch_depth: int = 1) -> Step:
return Step(
name="Checkout code",
uses="actions/checkout@v4",
with_params={"fetch-depth": str(fetch_depth)},
)
def setup_python_step(version: str = "3.12") -> Step:
return Step(
name=f"Setup Python {version}",
uses="actions/setup-python@v5",
with_params={"python-version": version},
)
def setup_uv_step() -> Step:
return Step(
name="Install uv",
uses="astral-sh/setup-uv@v4",
)
Generacion de un workflow real
def generate_python_ci(packages: list[str], python_version: str = "3.12") -> dict:
"""Genera CI para N paquetes Python con lint y test en paralelo."""
builder = WorkflowBuilder("Python CI")
builder.on_push(branches=["main"]).on_pull_request(branches=["main"])
lint_job = Job(
name="Lint",
steps=[
checkout_step(),
setup_python_step(python_version),
setup_uv_step(),
Step(name="Install dependencies", run="uv sync --all-packages"),
Step(name="Run ruff", run="uv run ruff check ."),
Step(name="Run ruff format check", run="uv run ruff format --check ."),
],
)
test_job = Job(
name="Test",
needs=["lint"],
strategy={
"matrix": {"package": packages},
"fail-fast": False,
},
steps=[
checkout_step(),
setup_python_step(python_version),
setup_uv_step(),
Step(name="Install dependencies", run="uv sync --all-packages"),
Step(
name="Run tests",
run="uv run pytest packages/${{ matrix.package }} -v --tb=short",
),
],
)
builder.add_job("lint", lint_job)
builder.add_job("test", test_job)
return builder.build()
Validadores
VALID_RUNNERS = {"ubuntu-latest", "ubuntu-22.04", "ubuntu-24.04", "macos-latest"}
DEPRECATED_ACTIONS = {
"actions/checkout@v3": "Upgrade to actions/checkout@v4",
"actions/setup-python@v4": "Upgrade to actions/setup-python@v5",
}
@dataclass
class ValidationError:
job_id: str
message: str
def validate_workflow(workflow: dict) -> list[ValidationError]:
"""Valida un workflow buscando errores comunes."""
errors: list[ValidationError] = []
for job_id, job in workflow.get("jobs", {}).items():
runner = job.get("runs-on", "")
if runner not in VALID_RUNNERS:
errors.append(ValidationError(
job_id=job_id,
message=f"Runner '{runner}' no es valido, usa: {VALID_RUNNERS}",
))
for step in job.get("steps", []):
action = step.get("uses", "")
if action in DEPRECATED_ACTIONS:
errors.append(ValidationError(
job_id=job_id,
message=f"Action deprecada '{action}': {DEPRECATED_ACTIONS[action]}",
))
if not job.get("permissions") and not workflow.get("permissions"):
errors.append(ValidationError(
job_id=job_id,
message="No hay permissions definidos (ni a nivel workflow ni job)",
))
return errors
Leccion clave
CI config es codigo que deberia generarse y validarse, no editarse a mano. Cuando tienes un monorepo con N paquetes, un builder que genera los workflows elimina la duplicacion y los validadores detectan errores antes de que GitHub te los reporte 10 minutos despues en un run fallido.