I’d been copy-pasting YAML for years.
Five projects, roughly the same shape — Go, Python, Rust, Ansible, Node. Each one had its .github/workflows/ci.yml with the same scanners (trivy fs ., gitleaks detect, the language-specific linter), the same matrix logic, the same caching trick I’d copied off a Stack Overflow answer in 2022. Each one slightly different from the others, because of a tweak I’d made in one and forgotten to backport.
The same scans existed in three places, on every project: in the pre-commit hook, in the local script I’d run when I remembered, and in CI. Three implementations of run Trivy against this repo. Three places where the version of Trivy could drift. Three opportunities for one of them to say PASS while the other two said FAIL. I’d stopped looking too closely.
Then a teammate I was pairing with spent two hours debugging a command that worked on his laptop but failed in CI. It was Trivy, and the version in his pre-commit was a year older than the one CI pulled fresh on each run. He’d fixed the lint, the local check passed, CI failed on a CVE that wasn’t visible to him at all. We worked out the version drift together. He apologized. I think I apologized too. We moved on.
Then I went back to my own projects and started counting Trivy invocations. I had eleven.
The shape of the problem
This is the shape of CI today, if you don’t think too hard about it:
- You write a
.github/workflows/ci.ymlper project. Maybe you use Composite Actions or Reusable Workflows to factor pieces out. They help. - You add a pre-commit hook with
pre-commit run --all-files. Different config file, different idea of “the latest” version of each tool. - For local iteration you wrap commands in a Makefile or a Taskfile, because nobody wants to type
docker run -v $(pwd):/scan …from memory. Different versions again. - You add act to run GitHub Actions locally. It approximates. It is not the same runtime.
Composite Actions, Reusable Workflows, Dagger, Earthly, Taskfile, act — each one solves a slice of this. None of them solve the slice I cared about: one declaration, executed identically locally and in CI.
You can build that on top of any of them. You can pin Docker tags, write a wrapper script, generate the YAML from a template, vendor a script in tools/. People do. I’d done versions of this several times. None of them survived contact with a sixth project.
The question I started asking
Why was I writing trivy fs . three times?
The local script ran docker run --rm aquasec/trivy:0.45.0 fs /scan. The pre-commit hook ran trivy fs . if command -v trivy succeeded. The CI workflow ran an aquasecurity/trivy-action@0.20.0 step with parameters in YAML. Three syntaxes. Three caching strategies. Three versions of Trivy. One intent.
If I really wanted reproducibility — not convergent results but actually reproducible — then all three needed to be the same command, in the same container image, with the same arguments. Not the same intention. The same byte string sent to the same binary. Anything else is a pun.
That’s a small enough rule that you can hold it in your head. And once I held it for a few days, it organized everything else.
What I built
cidx is the smallest tool I could write that holds that rule.
A cidx.toml declares phases — security, code, test, build — and each phase declares its containers:
[[phase.security]]
preset = "trivy"
[[phase.security]]
preset = "gitleaks"
[[phase.code]]
preset = "golangci-lint"
[pipeline.ci]
phases = ["security", "code", "test", "build"]
cidx run ci executes the pipeline locally. Each phase pulls its container, runs the preset’s exact command, reports. The version of Trivy is pinned in the preset — same image, same digest, same flags. Wherever you run cidx run ci, you get the same thing.
cidx generate github writes the corresponding .github/workflows/cidx.yml. The generated YAML doesn’t reimplement the logic — it installs cidx with go install and calls cidx run <phase> for each job. The CI runs the same binary you ran locally, against the same containers, with the same preset definitions. The two paths converge on one source of truth: the TOML.
The pre-commit hook is a shell line: cidx run pr. If your commit passes, your CI passes. That’s the entire promise. It either holds or it doesn’t.
What it costs
The trade-offs are honest:
- You need Docker or Podman installed locally. cidx never touches your host beyond that.
- Container pulls are slower than running
golangci-lintfrom$PATH. Cached layers help; the first run on a new machine is a few minutes. - You commit a
cidx.toml. It’s 15-30 lines. It’s also the only thing cidx writes into your repo. - For the very specific case of a matrix build across five Go versions, you’re better off with Composite Actions. cidx is intentionally not Turing-complete.
I keep cidx on every project I run. It has eaten the Makefile slot, the pre-commit slot, and the workflow-write slot. It hasn’t eaten my Taskfile — task still drives release flows, local servers, dev tools. Different scope.
What this is not
It’s not a replacement for GitHub Actions or GitLab CI. cidx generates their YAML; they remain the runtime. If you removed cidx tomorrow, the YAML keeps working — there’s no cidx daemon to be missing.
It’s not an orchestrator. For provisioning VMs and deploying services I have a separate tool (still private for now). Different problem, different file.
It’s not a framework that wants to organize your project. cidx writes exactly one file (cidx.toml) and reads it. There’s no .cidx/ directory tree. There’s no convention you have to follow.
It’s not a full DevOps platform covering plan-to-monitor. It’s a tool for the run-it-now part of the loop. For the people part of the same loop, see DevOps Is People. The Rest Is Tooling. — same author, related conviction.
What’s next
cidx has been public since 2026-05-09. v1.7.0 is the most recent release, ships with forty-plus presets, and the test suite now covers the generator output. The thing that still needs work is the integration story for projects that already have a deep .github/workflows/ investment — porting incrementally rather than rewriting wholesale. I have notes; I haven’t shipped that part.
Install:
go install github.com/cidx-org/cidx/cmd/cidx@latest
cidx init
cidx run ci
If it solves a problem for you, tell me. If it creates one, tell me too. Either way is a signal I want.