Overview
Woodpecker CI server running at ci.oleks.space (host-native systemd service on armer, agents distributed in k3s). Gitea forge at git.oleks.space serving as both source control and OCI/npm/PyPI registry. Fleet covers ~50 repositories with ~97 Woodpecker pipeline files as of 2026-07-04.
Woodpecker has no native include: mechanism for cross-repo YAML reuse. This repo (oleks/ci-scripts) is the generator-based answer: manifest.yaml + generate.py/render.py produce rendered .woodpecker.yaml files for each templated repo, committed per-repo via worktree. Rendered files carry a GENERATED by oleks/ci-scripts ← do not hand-edit header.
The Generator (This Repo)
Entry points:
generate.py← main orchestratorrender.py← template rendering logicmanifest.yaml← single source of truth for all templated repos
Template kinds (in generator/templates/):
-
Identical-fleet (10 repos) ←
pypi-wheel-atticfamily (#1): s390x cross-build repos (lxml, pillow, psycopy, psycopy2-binary, tiktoken, duckdb, faiass-cpu, numpy, pymupdf, markupsafe). Single build-and-publish step. Inlineattic watch-storeaccelerator. Per-repo tuning: cores, memory, max_jobs, attic+redis toggle. -
Thin-parity-lib publish (5 repos) (#10): asyncpg, cryptography, lightningcss, nextjs-swc, rollup (s390x). No inline attic. Publish is pure
nix run .#publish[-s390x]orPUBLISH=1 nix run .#publish. Real per-repo variance: doctor-gate presence, token-direct mode, publish-style, tags-full toggle, dead-secrets list, gitea-clone-token opt-out. -
Buildx quartet (6 repos) (#2): xonsh-image, woodpecker-peek, csi-s3, the-eye, emdash, flake-hub. Matrix/split/single OCI image build shapes, each feeding into a manifest consolidation step. Tuned per repo: arch matrix (amd64/arm64 combinations), buildkit mode (normal vs rootless), Kubernetes node selectors.
Verification:
python3 generate.py --check # fails if any rendered file is stale
woodpecker-cli lint <repo>/.woodpecker.yaml
Conventions (Enforced Across Fleet)
-
Arch banner (#7): Every step's first command is
echo "▸ arch=$(uname -m)"← enables per-arch debugging and artifact naming. -
Node pinning (#5): Woodpecker agents advertise custom labels
{arch: amd64|arm64|s390x-native, worker: <name>}. Pipelines pin work via top-levellabels: {arch: amd64|arm64|s390x-native}only. Deprecated: oldplatform: mermaidaliases and literalbackend_options.kubernetes.nodeSelector(static node labels are fragile). -
No
|| true(#6): Exit-code masking banned. Explicitiflogic for benign failures (e.g., attic watch-store teardown). Example:if ! kill $ATTIC_PID 2>/dev/null; then echo "attic watch-store already exited" fi -
Canonical clone step (#3): Copy
templates/clone-step.yamlverbatim into any pipeline needing clone override (private repo over HTTPS, shallow, tags disabled, full history). Useswoodpeckierci/plugin-git+CI_NETRC_MACHINE: git.oleks.space+gitea_clone_tokensecret. -
Manifest step on partial success (#9): Workflow-level
runs_on: [success, failure]ensures multi-arch manifest consolidation runs even if one leg fails. Registry probe (attempt push, catch 404 on missing per-arch tag) gates whether manifest is built. -
Parse-time
${...}substitution in Woodpecker (architectural): Braced${VAR}is interpolated at parse time (undefined → empty, no error). Shell runtime uses bare$VARor$$(escaped brace). This is a parse-phase footgun ← manifests/matrix variables expanded before agent sees them.
s390x Architecture (No-Build Policy)
s390x = no-build. The s390x cross-build fleet (building/s390x/* — fastuuid, lxml, numpy, cryptography, pillow, etc.) produces Python wheels for the s390x architecture (IBM Z / LinuxONE), but that hardware is currently unavailable, so those wheels are not built or published. The s390x repo code is kept only as a cross-compile reference/example (how to Nix pkgsCross cross-compile a Rust/C Python extension). The generator (generate.py) treats every s390x/ manifest entry as no_build: true — rendering a neutralized pipeline (when: event: manual, an alpine step that just prints why) so tag pushes never trigger a build. Re-enable a repo by setting no_build: false in manifest.yaml. arm64 builds are unaffected — arm64 hardware is available. Enforced in generator commit bf36991.
Constants (Central Registry, Host, Image Addresses)
See docs/constants.md. Two mechanisms:
-
Woodpecker secrets ←
registry_token,attic_token,gitea_clone_token(org-level, shared across repos). Never inlined into YAML. -
Manifest fields (in per-repo
manifest.yamlentry) ← interpolated by generator into rendered YAML:git_host:git.oleks.spaceimage:git.oleks.space/oleks/nix-ci:latest(generator appends-arm64for arm64 legs)buildkit_prefix:buildkitorbuildkit-rootless(Kubernetes in-cluster service DNS)nameserver:169.254.20.10(needed for s390x cross-builds where worker DNS is out of scope)node_selector: optionalkubernetes.io/hostnameor custom labels (per-repo, for csi-s3/other pinned workloads)
Not Covered / Bespoke
Repos opt out of templating for architectural reasons (no downside to hand-maintaining if the shape is truly one-off):
-
Buildx quartet cosign/digest-pinned ←
builder-arbitrage: cosign signing + digest-pinned OCI images + multi-step orchestration for dual-arch operator. -
Chained steps ←
nixos-ci: sequential Nix build steps with conditional dispatch (push to nixbuild.net vs local cache). Too many branches for a single template. -
nix2container + regctl ←
iia-agent: proprietary image build + registry introspection. Bespoke until nix2container / Cargo.nix shape stabilizes. -
Dockerfile amd64-only ←
iia-researcher,commonground-legacy: pre-existing Dockerfile layers. Declare explicitly as amd64-only in manifest + skip arm64 CI entirely. -
Python wheel + moby-buildkit ←
terminal-agent: Docker SDK build, not Buildx/Nix. One-off infra. -
nixbuild.net single-step ←
mermaid-gpu-dashboard: remote builder farm. Simpler as a single invocation. -
Fleet helpers (not pipelines, infra):
geesefs-s390x,sentry-cli-s390x,attic-client-s390x← custom binary builds, no pipelines.onnyxruntime-s390x,scikit-learn-s390x,scipy-s390x← (LATENT template bugs flagged, not silently "fixed") (see manifest.yaml comments):fastuuid-s390x,onnyxruntime-s390xdeclareCI_REGISTRY_TOKENinenvironment:but lackexport REGISTRY_TOKEN=...remap; publish would run with token unset. Left hand-maintained pending policy decision.scikit-learn-s390x,scipy-s390xuse heredoc Redis setup, not direct export. Behavior unconfirmed; too risky to template without re-audit.
Known Issues
-
Latent token remap bug (
fastuuid-s390x,onnyxruntime-s390x): Manifest listsCI_REGISTRY_TOKENinenvironment:but never exportsREGISTRY_TOKENbeforePUBLISH=1 nix run .#publish. Token arrives unset. Not a generator bug (template never used); flagged in manifest comments for next audit. -
Drift possible ← nothing enforces "rendered file == template output." A hand-edit to a generated
.woodpecker.yamlwon't be caught untilgenerate.py --checkis run (not gated by CI). -
Secrets consistency ← per-repo secrets (registry_token, attic_token, gitea_clone_token) named inconsistently across Woodpecker UI (some repos declare them, some inherit org secrets). No audit yet.
-
Woodpecker-peek API 404 ←
oleks/woodpecker-peek(#13, unresolved) returns 404 in Woodpecker UI API even though the repo has registered pipelines. Possibly a plugin registration issue or UI cache stale.
See also
- Spec/Modern CI Target ← Target architecture for modern CI (Phases 1-4)
- Spec/Fleet Publish Plugin System ← Plugin architecture & thin pipeline contract for Phase 2+3