P20: Gitea Actions backend — fix #151 #152 #154 #155

- [ci.gitea] section in stack.toml with runner_labels,
  registry_secret, uv_version
- backends/gitea.py: emits .gitea/workflows/*.yml — thin adapter
  over GitHub backend, uses platform registry + PAT auth
  (GITEA_TOKEN lacks package scope)
- Sequential builds (no matrix) to avoid Gitea matrix bugs
- gen_config.py: 3-way stale cleanup for woodpecker/github/gitea
- gen_config.py --backend gitea tested end-to-end

Three backends, one config file:
  ci.backend = "woodpecker" → .woodpecker/*.yml
  ci.backend = "github"     → .github/workflows/*.yml
  ci.backend = "gitea"      → .gitea/workflows/*.yml
This commit is contained in:
kert
2026-03-23 20:55:07 -04:00
parent fb5a1135eb
commit 2ea09ed5c0
3 changed files with 488 additions and 17 deletions

View File

@@ -0,0 +1,465 @@
"""Gitea Actions workflow emitter.
Thin adapter over the GitHub backend — Gitea Actions uses the same
YAML syntax but with:
- Output to .gitea/workflows/ (not .github/workflows/)
- Registry auth via PAT secret (GITEA_TOKEN lacks package scope)
- Platform registry (not ghcr.io)
- Full URL form for actions (for air-gapped installs)
"""
from __future__ import annotations
from backends.github import (
_HEADER,
_build_push_step,
_checkout_step,
_setup_buildx_step,
_setup_uv_step,
_trivy_step,
)
# ── Gitea-specific helpers ───────────────────────────────────────
def _docker_login_step(registry: str) -> str:
"""Login using PAT secret — GITEA_TOKEN can't push to registry."""
return f"""\
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: {registry}
username: ${{{{ secrets.REGISTRY_USER }}}}
password: ${{{{ secrets.REGISTRY_TOKEN }}}}"""
# ── Workflow generators ──────────────────────────────────────────
def _gen_ci(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
content = f"""\
{_HEADER}
name: CI
on:
push:
branches: ["**"]
pull_request:
jobs:
lint-test:
runs-on: {runner}
steps:
{_checkout_step()}
{_setup_uv_step(uv_version)}
- name: Install dependencies
run: uv sync --dev
- name: Ruff check
run: uv run ruff check src/ tests/ --output-format=concise
- name: Ruff format
run: uv run ruff format --check src/ tests/
- name: Pytest
run: uv run pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=99 -q
- name: Validate generated config
run: uv run python dev/scripts/gen_config.py --check
"""
return (".gitea/workflows/ci.yml", content)
def _gen_deploy(
images: list[dict],
scans: list[dict],
registry: str,
owner_repo: str,
runner: str,
uv_version: str,
**_kw: object,
) -> tuple[str, str]:
# Build steps — sequential (Gitea matrix has edge cases)
build_steps = []
for img in images:
tags = (
f"{registry}/{owner_repo}/{img['name']}:${{{{ env.SHORT_SHA }}}},"
f"{registry}/{owner_repo}/{img['name']}:latest"
)
build_steps.append(_build_push_step(img, tags, registry, owner_repo))
build_block = "\n\n".join(build_steps)
scan_steps = []
for img in scans:
scan_steps.append(
_trivy_step(img, "${{ env.SHORT_SHA }}", registry, owner_repo)
)
scan_block = "\n\n".join(scan_steps)
vuln_files = " ".join(f"{i['name']}-scan.json" for i in scans)
content = f"""\
{_HEADER}
name: Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: {runner}
steps:
{_checkout_step()}
{_setup_buildx_step()}
{_docker_login_step(registry)}
- name: Compute short SHA
run: echo "SHORT_SHA=${{{{GITHUB_SHA::8}}}}" >> "$GITHUB_ENV"
{build_block}
scan:
runs-on: {runner}
needs: build
steps:
{_checkout_step()}
- name: Compute short SHA
run: echo "SHORT_SHA=${{{{GITHUB_SHA::8}}}}" >> "$GITHUB_ENV"
{scan_block}
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: trivy-scans
path: "*-scan.json"
report-vulns:
runs-on: {runner}
needs: scan
steps:
{_checkout_step()}
{_setup_uv_step(uv_version)}
- name: Download scan results
uses: actions/download-artifact@v4
with:
name: trivy-scans
- name: Report vulnerabilities
env:
GITEA_TOKEN: ${{{{ secrets.GITEA_TOKEN }}}}
run: |
uv sync --no-dev
for f in {vuln_files}; do
[ -f "$f" ] && uv run python -m api.diag.vuln "$f" || true
done
"""
return (".gitea/workflows/deploy.yml", content)
def _gen_harden(
images: list[dict],
scans: list[dict],
registry: str,
owner_repo: str,
runner: str,
uv_version: str,
**_kw: object,
) -> tuple[str, str]:
build_steps = []
for img in images:
tags = (
f"{registry}/{owner_repo}/{img['name']}:hardened,"
f"{registry}/{owner_repo}/{img['name']}:latest"
)
build_steps.append(
_build_push_step(img, tags, registry, owner_repo, no_cache=True)
)
build_block = "\n\n".join(build_steps)
scan_steps = []
for img in scans:
scan_steps.append(_trivy_step(img, "hardened", registry, owner_repo))
scan_block = "\n\n".join(scan_steps)
vuln_files = " ".join(f"{i['name']}-scan.json" for i in scans)
content = f"""\
{_HEADER}
name: Harden
on:
workflow_dispatch:
schedule:
- cron: "0 2 * * 0"
jobs:
build:
runs-on: {runner}
steps:
{_checkout_step()}
{_setup_buildx_step()}
{_docker_login_step(registry)}
{build_block}
scan:
runs-on: {runner}
needs: build
steps:
{_checkout_step()}
{scan_block}
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: trivy-scans-harden
path: "*-scan.json"
close-or-report-vulns:
runs-on: {runner}
needs: scan
steps:
{_checkout_step()}
{_setup_uv_step(uv_version)}
- name: Download scan results
uses: actions/download-artifact@v4
with:
name: trivy-scans-harden
- name: Close resolved or file new vuln issues
env:
GITEA_TOKEN: ${{{{ secrets.GITEA_TOKEN }}}}
run: |
uv sync --no-dev
for f in {vuln_files}; do
if [ -f "$f" ]; then
uv run python -m api.diag.vuln --close "$f" || \\
uv run python -m api.diag.vuln "$f" || true
fi
done
"""
return (".gitea/workflows/harden.yml", content)
def _gen_rebuild_all(
images: list[dict],
scans: list[dict],
registry: str,
owner_repo: str,
runner: str,
uv_version: str,
**_kw: object,
) -> tuple[str, str]:
build_steps = []
for img in images:
tags = (
f"{registry}/{owner_repo}/{img['name']}:${{{{ env.SHORT_SHA }}}},"
f"{registry}/{owner_repo}/{img['name']}:latest"
)
build_steps.append(_build_push_step(img, tags, registry, owner_repo))
build_block = "\n\n".join(build_steps)
scan_steps = []
for img in scans:
scan_steps.append(
_trivy_step(img, "${{ env.SHORT_SHA }}", registry, owner_repo)
)
scan_block = "\n\n".join(scan_steps)
vuln_files = " ".join(f"{i['name']}-scan.json" for i in scans)
content = f"""\
{_HEADER}
name: Rebuild All
on:
workflow_dispatch:
jobs:
build:
runs-on: {runner}
steps:
{_checkout_step()}
{_setup_buildx_step()}
{_docker_login_step(registry)}
- name: Compute short SHA
run: echo "SHORT_SHA=${{{{GITHUB_SHA::8}}}}" >> "$GITHUB_ENV"
{build_block}
scan:
runs-on: {runner}
needs: build
steps:
{_checkout_step()}
- name: Compute short SHA
run: echo "SHORT_SHA=${{{{GITHUB_SHA::8}}}}" >> "$GITHUB_ENV"
{scan_block}
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: trivy-scans-rebuild
path: "*-scan.json"
report-vulns:
runs-on: {runner}
needs: scan
steps:
{_checkout_step()}
{_setup_uv_step(uv_version)}
- name: Download scan results
uses: actions/download-artifact@v4
with:
name: trivy-scans-rebuild
- name: Report vulnerabilities
env:
GITEA_TOKEN: ${{{{ secrets.GITEA_TOKEN }}}}
run: |
uv sync --no-dev
for f in {vuln_files}; do
[ -f "$f" ] && uv run python -m api.diag.vuln "$f" || true
done
"""
return (".gitea/workflows/rebuild-all.yml", content)
def _gen_infra_ci(
images: list[dict],
runner: str,
**_kw: object,
) -> tuple[str, str]:
all_paths: list[str] = []
for img in images:
all_paths.extend(img.get("path_filter", []))
paths_block = "\n".join(f" - {p!r}" for p in all_paths)
jobs_parts = []
for img in images:
if not img.get("hadolint", True):
continue
name = img["name"]
jobs_parts.append(f"""\
{name}:
runs-on: {runner}
steps:
{_checkout_step()}
- name: Hadolint {name}
uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: {img["dockerfile"]}
{_setup_buildx_step()}
{_build_push_step(img, f"ci-test-{name}", "", "", load_only=True)}""")
jobs_block = "\n\n".join(jobs_parts)
content = f"""\
{_HEADER}
name: Infra CI
on:
push:
paths:
{paths_block}
pull_request:
paths:
{paths_block}
jobs:
{jobs_block}
"""
return (".gitea/workflows/infra-ci.yml", content)
def _gen_release(runner: str, uv_version: str, **_kw: object) -> tuple[str, str]:
content = f"""\
{_HEADER}
name: Release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: {runner}
steps:
{_checkout_step()}
{_setup_uv_step(uv_version)}
- name: Build package
run: uv build --out-dir dist/
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.whl
dist/*.tar.gz
"""
return (".gitea/workflows/release.yml", content)
# ── Public API ────────────────────────────────────────────────────
def emit(
images: list[dict],
scans: list[dict],
platform: dict,
ci_cfg: dict,
) -> dict[str, str]:
"""Return {relative_path: content} for all Gitea Actions workflows."""
registry = platform["registry"] # Use platform registry, not ghcr.io
owner_repo = platform["repo"]
runner = ci_cfg.get("runner_labels", ["ubuntu-latest"])[0]
uv_version = ci_cfg.get("uv_version", "latest")
common = dict(
images=images,
scans=scans,
registry=registry,
owner_repo=owner_repo,
runner=runner,
uv_version=uv_version,
)
files: dict[str, str] = {}
for gen_fn in (
_gen_ci,
_gen_deploy,
_gen_harden,
_gen_rebuild_all,
_gen_infra_ci,
_gen_release,
):
path, content = gen_fn(**common) # type: ignore[arg-type]
files[path] = content
return files

View File

@@ -80,9 +80,10 @@ def gen_coredns() -> dict[str, str]:
# ── Backend dispatch ─────────────────────────────────────────────
STALE_DIRS = {
"woodpecker": ".github/workflows",
"github": ".woodpecker",
STALE_DIRS: dict[str, list[str]] = {
"woodpecker": [".github/workflows", ".gitea/workflows"],
"github": [".woodpecker", ".gitea/workflows"],
"gitea": [".woodpecker", ".github/workflows"],
}
@@ -99,6 +100,8 @@ def _emit_backend(backend: str) -> dict[str, str]:
from backends.woodpecker import emit
elif backend == "github":
from backends.github import emit
elif backend == "gitea":
from backends.gitea import emit
else:
print(f"Unknown CI backend: {backend}", file=sys.stderr)
sys.exit(1)
@@ -107,21 +110,19 @@ def _emit_backend(backend: str) -> dict[str, str]:
def _clean_stale(backend: str, generated_files: dict[str, str]) -> list[str]:
"""Return stale files from the inactive backend directory."""
stale_dir = STALE_DIRS.get(backend, "")
if not stale_dir:
return []
stale_path = ROOT / stale_dir
if not stale_path.exists():
return []
"""Return stale files from inactive backend directories."""
stale_dirs = STALE_DIRS.get(backend, [])
removed = []
for f in sorted(stale_path.glob("*.yml")):
rel = str(f.relative_to(ROOT))
if rel not in generated_files:
# Only remove files that look generated
content = f.read_text()
if "generated by gen_config.py" in content:
removed.append(rel)
for stale_dir in stale_dirs:
stale_path = ROOT / stale_dir
if not stale_path.exists():
continue
for f in sorted(stale_path.glob("*.yml")):
rel = str(f.relative_to(ROOT))
if rel not in generated_files:
content = f.read_text()
if "generated by gen_config.py" in content:
removed.append(rel)
return removed

View File

@@ -131,6 +131,11 @@ runner = "ubuntu-latest"
registry = "ghcr.io"
uv_version = "latest"
[ci.gitea]
runner_labels = ["ubuntu-latest"]
registry_secret = "REGISTRY_TOKEN"
uv_version = "latest"
[lint]
line_length = 88