Files
stack/dev/scripts/backends/github.py
kert 5aa043daea chore: drop deprecated Woodpecker references
Woodpecker CI is gone from the runtime; this purges the stale
mentions in env/dev scripts, dashboards, homepages, CSS comments,
and READMEs. Renames the SSO admin env var WOODPECKER_ADMIN to
GITEA_ADMIN to match where the admin actually lives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:56:34 -04:00

665 lines
16 KiB
Python

"""GitHub Actions workflow emitter.
Generates .github/workflows/*.yml from the same image definitions
used by the Gitea Actions backend. All output is valid GitHub Actions YAML.
Usage (called by gen_config.py)::
from backends.github import emit
files = emit(images, scans, platform, ci_cfg)
"""
from __future__ import annotations
# ── Constants ─────────────────────────────────────────────────────
_HEADER = (
"# DO NOT EDIT — generated by gen_config.py from stack.toml\n"
"# Re-generate: uv run python dev/scripts/gen_config.py\n"
)
_SHA_SHORT = 'echo "${GITHUB_SHA::8}"'
# ── Helpers ───────────────────────────────────────────────────────
def _indent(text: str, spaces: int = 0) -> str:
"""Indent every line of *text* by *spaces*."""
prefix = " " * spaces
return "\n".join(prefix + line if line else "" for line in text.splitlines())
def _trivy_step(
img: dict,
tag: str,
registry: str,
owner_repo: str,
) -> str:
"""aquasecurity/trivy-action step for one image."""
name = img["name"]
sev = img.get("trivy_severity", "HIGH,CRITICAL")
ec = img.get("trivy_exit_code", 0)
return f"""\
- name: Scan {name}
uses: aquasecurity/trivy-action@master
with:
image-ref: {registry}/{owner_repo}/{name}:{tag}
severity: {sev}
exit-code: "{ec}"
format: json
output: {name}-scan.json"""
def _docker_login_step(registry: str) -> str:
return f"""\
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: {registry}
username: ${{{{ github.actor }}}}
password: ${{{{ secrets.GITHUB_TOKEN }}}}"""
def _checkout_step() -> str:
return """\
- name: Checkout
uses: actions/checkout@v4"""
def _setup_buildx_step() -> str:
return """\
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3"""
def _setup_uv_step(uv_version: str) -> str:
return f"""\
- name: Set up uv
uses: astral-sh/setup-uv@v4
with:
version: {uv_version}"""
def _build_push_step(
img: dict,
tags_expr: str,
registry: str,
owner_repo: str,
*,
no_cache: bool = False,
load_only: bool = False,
) -> str:
name = img["name"]
push = "false" if load_only else "true"
lines = f"""\
- name: Build {name}
uses: docker/build-push-action@v6
with:
context: {img["context"]}
file: {img["dockerfile"]}
push: {push}
tags: {tags_expr}"""
if load_only:
lines += "\n load: true"
if no_cache:
lines += "\n no-cache: true"
return lines
# ── Failure reporting ─────────────────────────────────────────────
def _failure_step(workflow_name: str, job_name: str) -> str:
"""Emit an if:failure() step that files a Gitea issue."""
return f"""\
- name: File failure issue
if: failure()
env:
GITEA_TOKEN: ${{{{ secrets.GITEA_TOKEN }}}}
run: |
uv sync --no-dev --quiet 2>/dev/null || true
uv run python -m api.diag.ci \\
--workflow "{workflow_name}" --job "{job_name}" \\
--run "${{{{ github.run_number }}}}" \\
--sha "${{{{ github.sha }}}}" \\
--ref "${{{{ github.ref }}}}" || true"""
# ── Workflow generators ───────────────────────────────────────────
def _gen_ci(
runner: str, uv_version: str, coverage_threshold: int = 99, **_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
# -n auto parallelizes across runner cores; pytest-cov combines
# results via [tool.coverage.run] parallel=true in pyproject.toml.
run: uv run pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under={coverage_threshold} -q -n auto
- name: Validate generated config
run: uv run python dev/scripts/gen_config.py --check
{_failure_step("CI", "lint-test")}
skinny-install:
runs-on: {runner}
strategy:
matrix:
extra: [conf, aco, api, bcda, bib, bls, ccw, cli, cms, opps, perf, pfs, rex]
steps:
{_checkout_step()}
{_setup_uv_step(uv_version)}
- name: Install stack[${{{{ matrix.extra }}}}]
run: uv sync --no-dev --extra ${{{{ matrix.extra }}}}
- name: Verify import
run: uv run python -c "import ${{{{ matrix.extra }}}}"
- name: Run module tests
run: |
if [ -d "tests/${{{{ matrix.extra }}}}" ]; then
uv run pytest "tests/${{{{ matrix.extra }}}}/" -x -q || true
fi
{_failure_step("CI", "skinny-install")}
"""
return (".github/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]:
# Matrix include list
matrix_entries = []
for img in images:
matrix_entries.append(
f" - name: {img['name']}\n"
f" dockerfile: {img['dockerfile']}\n"
f" context: {img['context']}"
)
matrix_block = "\n".join(matrix_entries)
# Scan steps (only for scannable images)
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)
# Report job vuln loop
vuln_loop_files = " ".join(f"{i['name']}-scan.json" for i in scans)
content = f"""\
{_HEADER}
name: Deploy
on:
push:
branches: [main]
jobs:
build-scan:
runs-on: {runner}
strategy:
matrix:
include:
{matrix_block}
steps:
{_checkout_step()}
{_setup_buildx_step()}
{_docker_login_step(registry)}
- name: Compute short SHA
run: echo "SHORT_SHA=$(echo $GITHUB_SHA | head -c 8)" >> "$GITHUB_ENV"
{
_build_push_step(
{
"name": "${{ matrix.name }}",
"dockerfile": "${{ matrix.dockerfile }}",
"context": "${{ matrix.context }}",
},
"{registry}/{owner_repo}/${{{{ matrix.name }}}}:${{{{ env.SHORT_SHA }}}},{registry}/{owner_repo}/${{{{ matrix.name }}}}:latest".format(
registry=registry, owner_repo=owner_repo
),
registry,
owner_repo,
)
}
{_failure_step("Deploy", "build-scan")}
scan:
runs-on: {runner}
needs: build-scan
steps:
{_checkout_step()}
- name: Compute short SHA
run: echo "SHORT_SHA=$(echo $GITHUB_SHA | head -c 8)" >> "$GITHUB_ENV"
{scan_block}
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: trivy-scans
path: "*-scan.json"
{_failure_step("Deploy", "scan")}
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_loop_files}; do
[ -f "$f" ] && uv run python -m api.diag.vuln "$f" || true
done
{_failure_step("Deploy", "report-vulns")}
"""
return (".github/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
build_steps = []
for img in images:
tags = f"{registry}/{owner_repo}/{img['name']}:hardened,{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
scan_steps = []
for img in scans:
scan_steps.append(_trivy_step(img, "hardened", registry, owner_repo))
scan_block = "\n\n".join(scan_steps)
vuln_loop_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-scan:
runs-on: {runner}
steps:
{_checkout_step()}
{_setup_buildx_step()}
{_docker_login_step(registry)}
{build_block}
{_failure_step("Harden", "build-scan")}
scan:
runs-on: {runner}
needs: build-scan
steps:
{_checkout_step()}
{scan_block}
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: trivy-scans-harden
path: "*-scan.json"
{_failure_step("Harden", "scan")}
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_loop_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
{_failure_step("Harden", "close-or-report-vulns")}
"""
return (".github/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
build_steps = []
for img in images:
tags = f"{registry}/{owner_repo}/{img['name']}:${{{{ env.SHORT_SHA }}}},{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
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_loop_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=$(echo $GITHUB_SHA | head -c 8)" >> "$GITHUB_ENV"
{build_block}
{_failure_step("Rebuild All", "build")}
scan:
runs-on: {runner}
needs: build
steps:
{_checkout_step()}
- name: Compute short SHA
run: echo "SHORT_SHA=$(echo $GITHUB_SHA | head -c 8)" >> "$GITHUB_ENV"
{scan_block}
- name: Upload scan results
uses: actions/upload-artifact@v4
with:
name: trivy-scans-rebuild
path: "*-scan.json"
{_failure_step("Rebuild All", "scan")}
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_loop_files}; do
[ -f "$f" ] && uv run python -m api.diag.vuln "$f" || true
done
{_failure_step("Rebuild All", "report-vulns")}
"""
return (".github/workflows/rebuild-all.yml", content)
def _gen_infra_ci(
images: list[dict],
runner: str,
**_kw: object,
) -> tuple[str, str]:
# Collect all path filters for global trigger
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) if all_paths else ""
# Per-image hadolint + dry-run build jobs
jobs_block_parts = []
for img in images:
if not img.get("hadolint", True):
continue
name = img["name"]
pf = img.get("path_filter", []) # noqa: F841 — reserved for future per-job filtering
jobs_block_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)}
{_failure_step("Infra CI", name)}""")
jobs_block = "\n\n".join(jobs_block_parts)
content = f"""\
{_HEADER}
name: Infra CI
on:
push:
paths:
{paths_block}
pull_request:
paths:
{paths_block}
jobs:
{jobs_block}
"""
return (".github/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}
permissions:
contents: write
steps:
{_checkout_step()}
{_setup_uv_step(uv_version)}
- name: Build package
run: uv build --out-dir dist/
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
files: |
dist/*.whl
dist/*.tar.gz
{_failure_step("Release", "release")}
"""
return (".github/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 GitHub Actions workflows.
Parameters
----------
images:
Merged image dicts from ``load_images()``.
scans:
Scannable subset (``scan=True``).
platform:
Dict with keys *registry*, *domain*, *host_ip*, *image_prefix*,
*repo* (``"org/name"`` form).
ci_cfg:
Dict from ``cfg.ci.github`` — *runner*, *registry*, *uv_version*.
"""
registry = ci_cfg["registry"]
owner_repo = platform["repo"]
runner = ci_cfg["runner"]
uv_version = ci_cfg["uv_version"]
common = dict(
images=images,
scans=scans,
registry=registry,
owner_repo=owner_repo,
runner=runner,
uv_version=uv_version,
coverage_threshold=ci_cfg.get("coverage_threshold", 99),
)
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