Self-hosted CI/CD

Why run CI on your own metal instead of GitHub Actions/Terraform Cloud: the work Gitea remote (spec: oktawave) already self-hosts the forge — CI next to it keeps source, secrets, and build execution inside the trust boundary (the Cilium hardening doctrine applies more when the whole pipeline is yours); no per-minute billing; no vendor policy deciding which workloads are acceptable; and the Golden repo CI section needs a concrete engine to hang its rules on.

The engines

  • Forgejo Actions + forgejo-runner (runner captured) — the natural pair for a Gitea/Forgejo forge: GitHub-Actions-compatible workflows (.forgejo/workflows/, near-drop-in reuse of existing action YAML), the runner is a Go daemon polling the forge over HTTP, stateless, label-routed (docker:lxc://…, self-hosted:host://…). Forked from Gitea Actions; the execution core is nektos/act (run GitHub Actions locally). Canonical repo lives on code.forgejo.org — Forgejo self-hosts its own CI (dogfooding as documentation). Practical notes from the field: enable [actions] ENABLED=true for source installs, pin runner image versions (updates change behavior), scope runners repo → org → instance as needed, route with labels (“build on ARM”, “deploy from trusted runner only”).
  • hydra (OSS) — the NixOS school (1.6k★): CI from Nix — jobsets declare builds in the Nix language, and the payoff is that builds are derivations: bit-reproducible, content-addressed, binary-cached (nix-cache), with per-build dependency graphs. Hydra is what NixOS itself uses to build the entire distribution. The trade: everything must be Nix-ified; in exchange, “works on my machine” and “CI passed” converge to the same artifact.
  • Woodpecker / Gitea Actions’ cousins — Woodpecker (drone fork, container-native, .woodpecker/ YAML) and Gitea’s own Actions round out the forge-adjacent field. (open: capture when relevant)
  • Concourse / Jenkins — the older self-hosted generations; Jenkins as the legacy incumbent everywhere in enterprise, Concourse as the pipeline-as-data purist. Mostly reference material now.

The decision axes

  • Compatibility — reusing GitHub-Actions-shaped YAML (Forgejo/Gitea) vs learning a pipeline DSL (Hydra=Nix, Woodpecker, Concourse)
  • Reproducibility — Hydra’s derivations are the ceiling (bit-identical rebuilds); container-job engines get close via pinned images (the Cilium @sha256: discipline)
  • Trust boundary — runners execute PR code; the Agent sandbox note’s containment rules apply to CI runners identically (dedicated VMs/containers, no prod creds, label-gated privileged jobs)
  • Ops cost — a polling Go binary (forgejo-runner) is near-zero; Hydra wants the Nix ecosystem around it; Jenkins wants a priesthood

For the homelab/work default: Forgejo-runner beside the existing Gitea is the lowest-friction golden-repo engine (compatible workflows, labels for routing, container isolation per job). Hydra is the bet to take when a project is already Nix-native — its reproducibility is the strongest form of the “CI = source of truth” idea.

Related: Golden repo (the rules; this note picks the engine) · Git hooks (hk’s hooks=CI unification — same config both places) · Homelabs (where runners would live) · floci (OSS) (the local-cloud cousin: test against emulators instead of real clouds)

The lint layer: actionlint’s maintained fork (2026-10-08)

jdxcode: “actionlint appears to be unmaintained so i made jactionlint” — jactionlint (OSS) (26★ Go; captured): an actively maintained fork of rhysd/actionlint with upstream PRs/fixes merged, plus a browser playground. The static checker for GitHub-Actions-shaped workflow files — which matters for this note’s engines because Forgejo/Gitea Actions reuse the GH Actions workflow syntax (.forgejo/workflows/ is near-drop-in): jactionlint’s checks apply to the self-hosted lane too. What it catches: syntax/missing keys; typed ${{ }} expression checks (nonexistent properties, type mismatches); action input/output correctness; reusable-workflow input/output/secrets checks; shellcheck + pyflakes integration for run: scripts; security checks (script injection via untrusted inputs — e.g. the classic github.event.head_commit.message interpolation — plus hard-coded credentials); glob syntax, needs: dependency, runner-label and cron validation. The jdx pattern repeats (fnox yesterday, jactionlint today): adopt → hit the maintenance wall → fork and carry it.

zizmor + jactionlint 2.0: the audit layer, and the Rust-compiler error aesthetic (2026-10-09)

zizmor.sh + zizmor (OSS) (woodruffw/Trail of Bits, 6,682★ Rust, MIT; site + repo captured): static analysis for CI/CD security — GitHub Actions, Dependabot, pre-commit. Finds template injection (attacker-controlled code execution), accidental credential persistence/leakage, excessive permission scopes, impostor commits and confusable git references. Design stance: “high signal-to-noise ratio by default” with pedantic and auditor personas to dial sensitivity up; SARIF output → GitHub code scanning (PR feedback inline, no context switching); offline-native (“first-class, the default without a GH_TOKEN; --offline even when one is set”). Sponsors: Grafana Labs, Trail of Bits, Kusari — the supply-chain-security establishment backs it.

jdxcode (tweet captured): jactionlint 2.0 — “actionlint, maintained, plus zizmor-style security audits in one binary”: rules actionlint 17 · zizmor 41 · jactionlint 130+ (“not entirely comparable”); --fix that converges; dependabot.yml + composite action linting; existing # zizmor: ignore comments keep working; baseline mode for gradual adoption. The convergence move: syntax checking (actionlint lineage) + security auditing (zizmor lineage) in one tool, migration-friendly.

USER NOTE, verbatim: “I love this kind of errors/warnings style in terminal — it reminds me rust compiler errors that shows you what’s wrong and at exact place it’s wrong with suggestions for fix.” That aesthetic is the lineage: rustc’s error rendering (labels at exact spans, notes, help-with-suggestion) → clippy → the tooling that adopted it (maturin-era Python packaging errors, cargo-style CLI polish everywhere). zizmor/jactionlint bring it to CI YAML — the file format where “line 34, column 12, this expression, here’s the attacker path” matters most. For agents: this error style is also the highest-bandwidth feedback loop a coding agent can consume — span-anchored, machine-parsable, fix-suggested (jactionlint’s converging --fix closes it fully).