Container-first CI · Apache-2.0
CI primitives as images, not actions.
A small, sharp toolchain that powers continuous integration
across a multi-repo SaaS family on Forgejo.
Container images first, composite actions second, drop-in
workflow templates third — and no central
workflow_call graph that breaks every consumer
at once.
What it is
Three layers, each smaller than the one above.
The image primitives are tcwlab/betterlint (multi-linter, ~300 MB Alpine), tcwlab/opentofu, tcwlab/buildx, tcwlab/trivy, and tcwlab/semantic-release. The Dockerfiles live publicly on github.com/tcwlab; the images on hub.docker.com/u/tcwlab. They run in Forgejo Actions, GitHub Actions, GitLab CI, or any substrate that takes container: jobs.
Above them sit a thin layer of composite actions for genuine glue logic, and on top a layer of drop-in workflow templates copied into each repo’s .forgejo/workflows/ — versioned by Git tag, edited in place. Drift is annoying. Cascading breakage across 50 repos is fatal.
Show, don’t tell
One .forgejo/workflows/ci.yml, every repo.
A lint-and-test job that pulls a pinned linter container and
runs against the workspace. Same shape across the whole family.
No setup-node@v4, no npm install -g,
no opaque caching layer.
name: ci
on: { push: { branches: [main] }, pull_request: {} }
jobs:
lint:
runs-on: [self-hosted, linux, x64]
container:
image: tcwlab/betterlint:2.12.0 # pinned at the container layer
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: betterlint
run: betterlint # auto-detects file types in /workspace
scan:
needs: [lint]
runs-on: [self-hosted, linux, x64]
container: { image: tcwlab/trivy:0.58.1 }
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- run: trivy fs --severity HIGH,CRITICAL --exit-code 1 .
# versions.yaml is the single source of truth — Renovate-friendly:
# tcwlab/betterlint: 2.12.0 ← grep-able from the whole family
# tcwlab/trivy: 0.58.1
Why it matters
Five reasons to put pinning at the container layer.
Pinning at the container layer.
setup-foo@v4 followed by npm install -g foo@x.y.z is a pinning loophole. A versioned container is not.
Drop-in templates beat workflow_call.
Drift across 50 repos is annoying. Cascading breakage is fatal. The drop-in pattern picks the survivable failure mode.
One versions.yaml for the whole toolchain.
The single source of truth for “what version of every tool is currently good.” Renovate-friendly.
Composite actions for glue, images for primitives.
Mixing the layers produces the “everything routes through one mega-action” pain. Don’t.
Self-bootstrapping.
betterlint lints its own source. Trivy scans its own Dockerfile. If a release breaks itself, the bootstrap goes red before downstream pulls.
versions.yaml snapshot, or a CI-architecture chat:
info@thechameleonway.com
Public mirrors — and where to pull from
— Apache-2.0, source-available.All five image primitives published, multi-arch, daily-pull-friendly.
GitHub mirrorPublic mirror of the Forgejo source. Star the org if you find it useful.
betterlint readmeAuto-detect linter wrapper · hadolint, tflint, shellcheck, markdownlint, commitlint, …
opentofu readmePinned OpenTofu CLI image. Tag mirrors upstream version.