#!/usr/bin/env python3 """Render Woodpecker buildx-quartet pipelines from a per-repo manifest. oleks/ci-scripts#2 — one generator, three supported shapes (`kind:` in the manifest), because the repos in scope aren't actually one shape: matrix - single build.yaml with a `matrix: {ARCH: [...]}` + manifest.yaml combining the per-arch tags (xonsh-image, csi-s3, ...). split - separate per-arch files (amd64.yaml, arm64.yaml, ...), no manifest step — used for attic-closure "nix run .#publish-*" publishes rather than docker buildx (woodpecker-peek, ...). single - one container.yaml, one native-arch build (+ optional extra components like a grpc image), no matrix, no manifest (the-eye, emdash.kotkanagrilli.fi). Usage: python3 generator/render.py manifests/.yaml --out /path/to/repo/.woodpecker Every rendered file gets a "GENERATED by oleks/ci-scripts — do not hand-edit" header. Re-running is idempotent: same manifest in, byte-identical files out. """ import argparse import pathlib import sys import yaml HERE = pathlib.Path(__file__).parent TEMPLATES = HERE / "templates" GENERATED_HEADER = ( "# GENERATED by oleks/ci-scripts — do not hand-edit.\n" "# Source manifest: {manifest_path}\n" "# Regenerate: python3 generator/render.py {manifest_path} --out /.woodpecker\n" "\n" ) def load_template(*parts): return (TEMPLATES / pathlib.Path(*parts)).read_text() def render_matrix(manifest: dict) -> dict: arches = manifest["arches"] image = manifest["image"] script = manifest.get("script", "ci/local.sh") buildkit_prefix = manifest.get("buildkit_prefix", "buildkit") node_selector = manifest.get("node_selector") timeout = manifest.get("timeout") manifest_arches_env = manifest.get("manifest_arches_env") arch_list = "\n".join(f" - {a}" for a in arches) timeout_block = f"\ntimeout: {timeout}\n" if timeout else "" if node_selector: lines = "\n".join(f" {k}: {v}" for k, v in node_selector.items()) node_selector_block = f" nodeSelector:\n{lines}\n" node_selector_top_block = ( " backend_options:\n kubernetes:\n nodeSelector:\n" + lines + "\n" ) else: node_selector_block = "" node_selector_top_block = "" manifest_extra_env = "" if manifest_arches_env: manifest_extra_env = f' MANIFEST_ARCHES: "{manifest_arches_env}"\n' if node_selector: manifest_extra_env = ( f' BUILDKIT_ADDR: "tcp://{buildkit_prefix}-{arches[0]}.infra.svc.cluster.local:1234"\n' + manifest_extra_env ) build = load_template("matrix", "build.yaml.tmpl").format( arch_list=arch_list, timeout_block=timeout_block, script=script, image=image, buildkit_prefix=buildkit_prefix, node_selector_block=node_selector_block, ) mnf = load_template("matrix", "manifest.yaml.tmpl").format( script=script, image=image, manifest_extra_env=manifest_extra_env, node_selector_top_block=node_selector_top_block, ) return {"build.yaml": build, "manifest.yaml": mnf} def render_split(manifest: dict) -> dict: repo = manifest["repo"] git_host = manifest.get("git_host", "git.oleks.space") setup_script = manifest.get("setup_script", "ci/setup.sh") out = {} tmpl = load_template("split", "PERARCH.yaml.tmpl") for arch, leg in manifest["legs"].items(): header = leg["header"].rstrip("\n") + "\n" entrypoint_comment = "".join( f" # {line}\n" if line else " #\n" for line in leg["entrypoint_comment"].rstrip("\n").splitlines() ) out[f"{arch}.yaml"] = tmpl.format( repo=repo, nix_system=leg["nix_system"], arch=arch, header=header, entrypoint_comment=entrypoint_comment, git_host=git_host, image=leg["image"], setup_script=setup_script, app=leg["app"], ) return out def render_single(manifest: dict) -> dict: repo = manifest["repo"] arch = manifest["arch"] git_host = manifest.get("git_host", "git.oleks.space") branches = "[" + ", ".join(manifest["branches"]) + "]" buildkit_addr = f"tcp://buildkit-{arch}.infra.svc.cluster.local:1234" step_tmpl = load_template("single", "COMPONENT_STEP.tmpl") steps = [] for c in manifest["components"]: command = f"./ci/local.sh --arch {arch}" if c.get("component"): command += f" --component {c['component']}" steps.append( step_tmpl.format( step_name=c["name"], image=manifest["image"], buildkit_addr=buildkit_addr, command=command, arch=arch, memory=c.get("memory", "4Gi"), ) ) container = load_template("single", "container.yaml.tmpl").format( repo=repo, arch=arch, branches=branches, git_host=git_host, component_steps="\n".join(steps), ) return {"container.yaml": container} RENDERERS = {"matrix": render_matrix, "split": render_split, "single": render_single} def main(): ap = argparse.ArgumentParser() ap.add_argument("manifest", type=pathlib.Path) ap.add_argument("--out", type=pathlib.Path, required=True, help="target .woodpecker/ dir") ap.add_argument("--check", action="store_true", help="don't write, just diff against --out") args = ap.parse_args() manifest = yaml.safe_load(args.manifest.read_text()) kind = manifest["kind"] if kind not in RENDERERS: sys.exit(f"unknown kind: {kind}") files = RENDERERS[kind](manifest) header = GENERATED_HEADER.format(manifest_path=args.manifest) args.out.mkdir(parents=True, exist_ok=True) changed = False for name, body in files.items(): target = args.out / name content = header + body if args.check: existing = target.read_text() if target.exists() else "" if existing != content: changed = True print(f"DIFF: {target}") else: target.write_text(content) print(f"wrote {target}") if args.check and changed: sys.exit(1) if __name__ == "__main__": main()