- [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 ─────────────────────────────────────────────
|
# ── Backend dispatch ─────────────────────────────────────────────
|
||||||
|
|
||||||
STALE_DIRS = {
|
STALE_DIRS: dict[str, list[str]] = {
|
||||||
"woodpecker": ".github/workflows",
|
"woodpecker": [".github/workflows", ".gitea/workflows"],
|
||||||
"github": ".woodpecker",
|
"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
|
from backends.woodpecker import emit
|
||||||
elif backend == "github":
|
elif backend == "github":
|
||||||
from backends.github import emit
|
from backends.github import emit
|
||||||
|
elif backend == "gitea":
|
||||||
|
from backends.gitea import emit
|
||||||
else:
|
else:
|
||||||
print(f"Unknown CI backend: {backend}", file=sys.stderr)
|
print(f"Unknown CI backend: {backend}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -107,18 +110,16 @@ def _emit_backend(backend: str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _clean_stale(backend: str, generated_files: dict[str, str]) -> list[str]:
|
def _clean_stale(backend: str, generated_files: dict[str, str]) -> list[str]:
|
||||||
"""Return stale files from the inactive backend directory."""
|
"""Return stale files from inactive backend directories."""
|
||||||
stale_dir = STALE_DIRS.get(backend, "")
|
stale_dirs = STALE_DIRS.get(backend, [])
|
||||||
if not stale_dir:
|
removed = []
|
||||||
return []
|
for stale_dir in stale_dirs:
|
||||||
stale_path = ROOT / stale_dir
|
stale_path = ROOT / stale_dir
|
||||||
if not stale_path.exists():
|
if not stale_path.exists():
|
||||||
return []
|
continue
|
||||||
removed = []
|
|
||||||
for f in sorted(stale_path.glob("*.yml")):
|
for f in sorted(stale_path.glob("*.yml")):
|
||||||
rel = str(f.relative_to(ROOT))
|
rel = str(f.relative_to(ROOT))
|
||||||
if rel not in generated_files:
|
if rel not in generated_files:
|
||||||
# Only remove files that look generated
|
|
||||||
content = f.read_text()
|
content = f.read_text()
|
||||||
if "generated by gen_config.py" in content:
|
if "generated by gen_config.py" in content:
|
||||||
removed.append(rel)
|
removed.append(rel)
|
||||||
|
|||||||
@@ -131,6 +131,11 @@ runner = "ubuntu-latest"
|
|||||||
registry = "ghcr.io"
|
registry = "ghcr.io"
|
||||||
uv_version = "latest"
|
uv_version = "latest"
|
||||||
|
|
||||||
|
[ci.gitea]
|
||||||
|
runner_labels = ["ubuntu-latest"]
|
||||||
|
registry_secret = "REGISTRY_TOKEN"
|
||||||
|
uv_version = "latest"
|
||||||
|
|
||||||
[lint]
|
[lint]
|
||||||
line_length = 88
|
line_length = 88
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user