- [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:
465
dev/scripts/backends/gitea.py
Normal file
465
dev/scripts/backends/gitea.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user