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.

Pull from Docker Hub or star on GitHub.
5 primitives betterlint · opentofu · buildx · trivy · semantic-release
1 versions.yaml single source of truth, Renovate-friendly
~300 MB betterlint vs ~1 GB MegaLinter
0 workflow_call no propagation 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.

.forgejo/workflows/ci.yml
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.

A copy-paste snippet, the versions.yaml snapshot, or a CI-architecture chat: