Compact multi-linter image for CI pipelines. Bundles every linter and auto-fixer that TCW projects actually need (
hadolint,tflint,shellcheck,shfmt,markdownlint,commitlint,spectral,gherkin,prettier,eslint,yamlfmt, plusjqandyq) into a single Alpine-based image with a smart auto-detect wrapper. Drop-in alternative to MegaLinter when you want a smaller, faster image with sensible defaults.
docker pull tcwlab/betterlint:latest
# Run against the current directory
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:latestOr as a Forgejo / GitHub-Actions container job:
lint:
runs-on: ubuntu-22.04
container:
image: tcwlab/betterlint:latest
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- run: betterlint --dir .The wrapper auto-detects which file types are present and runs only the relevant linters — no configuration needed for the common case.
Quick-start examples use
:latestso you can try the image immediately. For production CI pipelines, pin a concrete tag — see Tags below.
Version numbers below are illustrative. For the current set of tags, see Docker Hub tags.
| Tag | Description |
|---|---|
1.0.0, 1.0, 1 |
Concrete SemVer (recommended for production pipelines) |
latest |
Rolling reference; always points at the newest release |
Always pin a concrete version in production. latest is fine for local experiments, but pinning protects your pipeline from a toolchain bump that lands without a PR. The major/minor floating tags (1, 1.0) are convenient for internal use; external consumers should pin the full SemVer.
The betterlint SemVer tracks the wrapper image as a whole, not any single bundled tool. When one of the underlying tools is bumped, the image typically takes a minor step.
linux/amd64linux/arm64
Every tag is a multi-arch manifest list. Docker pulls the right architecture automatically.
| Tool | Version | Purpose |
|---|---|---|
hadolint |
2.14.0 |
Dockerfile linting |
tflint |
0.62.0 |
Terraform / OpenTofu linting |
shellcheck |
from Alpine 3.23 apk | Shell script linting |
shfmt |
from Alpine 3.23 apk | Shell script auto-formatter (used by --fix) |
markdownlint-cli2 |
0.22.1 |
Markdown linting (auto-fix supported) |
@commitlint/cli |
20.x |
Conventional Commits validation |
@commitlint/config-conventional |
20.x |
Default commitlint rule set |
@stoplight/spectral-cli |
6.15.1 |
OpenAPI / AsyncAPI linting |
gherkin-official |
39.0.0 |
Gherkin/BDD .feature syntax validation |
prettier |
^3 |
JS/TS/JSON/CSS/SCSS/HTML formatter (used by --fix) |
eslint |
^9 |
JS/TS linter + auto-fixer (used by --fix; ships flat-config default) |
yamlfmt |
0.13.0 |
YAML auto-formatter (used by --fix) |
jq |
from Alpine apk | JSON processor (validation + filter) |
yq |
from Alpine apk | YAML processor (validation + filter) |
betterlint (wrapper) |
1.0.0 |
Auto-detect runner (/usr/local/bin/betterlint) |
Base image: node:24-alpine. Default workdir: /workspace. Default user: betterlint (non-root, fixed UID/GID 10001:10001). See Security posture for the rationale and the recommended hardened docker run line.
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:1.0.0The wrapper inspects /workspace, picks the linters whose file patterns match, and skips the rest. Skipped linters appear in the report with a ⏭️ marker so you can see why nothing ran.
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:1.0.0 \
--only hadolint,shellcheckdocker run --rm -v "$PWD:/workspace" tcwlab/betterlint:1.0.0 \
--skip commitlint,spectraldocker run --rm -v "$PWD:/workspace" tcwlab/betterlint:1.0.0 \
--markdown > lint-report.mdThe image's
ENTRYPOINTis locked to/usr/local/bin/betterlint, so everydocker runinvocation passes its arguments straight to the CLI. No need to retypebetterlintbetween the image tag and the flag. (Old callers that still write… tcwlab/betterlint:1.0.0 betterlint --only fookeep working — the wrapper silently absorbs a leadingbetterlintarg for back-compat.)
lint:
runs-on: ubuntu-22.04
container:
image: tcwlab/betterlint:1.0.0
steps:
- uses: https://data.forgejo.org/actions/checkout@v4
- name: Run betterlint
run: |
set +e
betterlint --dir . --markdown 2>&1 | tee /tmp/betterlint-report.md
LINT_EXIT="${PIPESTATUS[0]}"
exit "${LINT_EXIT}"| Flag | Description |
|---|---|
--skip TOOL,... |
Skip these tools (comma-separated list) |
--only TOOL,... |
Run only these tools (comma-separated list) |
--dir PATH |
Target directory (default: /workspace) |
--fix |
Run auto-correctable fixers in-place, then lint as usual (details) |
--markdown |
Emit a Markdown table (suitable for PR comments) |
--version |
Print the wrapper version and exit |
--help |
Print the full help text |
Equivalent environment variables (overridden by CLI flags):
| Variable | Equivalent flag |
|---|---|
BETTERLINT_SKIP |
--skip |
BETTERLINT_ONLY |
--only |
BETTERLINT_DIR |
--dir |
BETTERLINT_FIX |
--fix (set to 1) |
The five canonical patterns:
| Action | Example |
|---|---|
| Run a single linter | betterlint --only markdownlint |
| Run a list of linters | betterlint --only markdownlint,shellcheck |
| Skip a single linter | betterlint --skip hadolint |
| Skip a list of linters | betterlint --skip hadolint,tflint |
| Combine selection + auto-fix | betterlint --fix --only markdownlint,shellcheck |
--only and --skip are mutually exclusive — the wrapper exits with
code 2 and a clear error message if both are passed. Unknown linter names
also fail fast with exit 2 instead of silently doing nothing.
Known linter / fixer names (use these exactly with --only / --skip):
hadolint, tflint, shellcheck, markdownlint, commitlint, spectral,
gherkin, prettier, eslint, yamlfmt. The last three are fix-only and
only run under --fix; in plain lint mode they are silently no-op even when
listed in --only.
The same patterns work via the host wrapper or as direct docker run
arguments (the image's ENTRYPOINT forwards everything to the CLI):
betterlint --only markdownlint # via shell function / wrapper
docker run --rm -v "$PWD:/workspace" \
tcwlab/betterlint:1.0.0 --only markdownlint # direct docker runIf your repo has no own configuration file for markdownlint, commitlint, spectral, or eslint, the wrapper falls back to defaults shipped inside the image at /etc/betterlint/defaults/:
| Tool | Default config | Notes |
|---|---|---|
markdownlint |
/etc/betterlint/defaults/markdownlint.json |
MD013 and MD033 disabled |
commitlint |
/etc/betterlint/defaults/commitlint.config.cjs |
Conventional Commits 1.0, header max 100 chars |
spectral |
/etc/betterlint/defaults/spectral.yaml |
Activates spectral:oas + spectral:asyncapi |
eslint |
/etc/betterlint/defaults/eslint.config.js |
Flat-config: @eslint/js recommended + browser/node/es2024 globals |
A consumer config in the workdir always wins. The wrapper looks for the standard config locations (.markdownlint*, .commitlintrc*, commitlint.config.*, .spectral.*, eslint.config.*, .eslintrc*) and only falls back to the defaults when none is found.
To point the wrapper at a different defaults directory (useful for local tests):
docker run --rm -v "$PWD:/workspace" -v "$PWD/my-defaults:/opt/my-defaults" \
-e BETTERLINT_DEFAULTS_DIR=/opt/my-defaults \
tcwlab/betterlint:1.0.0| Path | Purpose |
|---|---|
/workspace |
Default workdir; mount your project here |
/etc/betterlint/defaults/ |
Image-side default configs (read-only) |
Path-agnostic scanning. The wrapper scans the container's current working directory, not a hardcoded
/workspace. The image'sWORKDIRis/workspaceso nakeddocker runlands there, but any custom mount
-wworks transparently —-v "$PWD:/work" -w /work,-v "$PWD:/repo" -w /repo, etc. Use--dir PATH(orBETTERLINT_DIR) to override explicitly.
| Tool | Patterns |
|---|---|
hadolint |
Dockerfile, Dockerfile.* |
tflint |
*.tf (per-directory) |
shellcheck |
*.sh |
markdownlint |
*.md |
commitlint |
latest git log -1 (only when the workdir is a git repo) |
spectral |
openapi*.yaml, openapi*.yml, asyncapi*.yaml, asyncapi*.yml |
gherkin |
*.feature |
# Pure lint (default — read-only)
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:latest
# Auto-fix where possible, then lint to report what's left
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:latest --fix--fix runs in two phases:
- Fix phase — every fix-capable tool patches matching files in-place.
- Lint phase — the regular linters run, including the ones that have no auto-fix mode. Anything not fixable surfaces here for manual cleanup.
The exit code is max() over both phases — so "all fixed but hadolint still
flagged something" still fails the run. Combinable with --skip / --only /
--dir (e.g. betterlint --fix --only markdownlint to fix only Markdown).
| Tool | Mode under --fix |
Notes |
|---|---|---|
markdownlint-cli2 |
🛠️ in-place fix (--fix) |
Bundled; *.md |
shfmt |
🛠️ in-place format (-w) |
Bundled; *.sh |
prettier |
🛠️ in-place fix (--write) |
Bundled; JS/TS/JSON/CSS/SCSS/HTML — not Markdown or YAML (handled by markdownlint and yamlfmt respectively, to avoid fix-loops) |
eslint |
🛠️ in-place fix (--fix) |
Bundled (v9, flat-config); flat-config default kicks in when the consumer ships no eslint.config.* / .eslintrc* |
yamlfmt |
🛠️ in-place format | Bundled; *.yaml / *.yml |
hadolint |
📋 report-only | No auto-fix mode upstream |
tflint |
📋 report-only | No auto-fix mode upstream |
shellcheck |
📋 report-only | Use shfmt for formatting; shellcheck reports semantic issues |
commitlint |
📋 report-only | Commit messages can't be auto-fixed |
spectral |
📋 report-only | No auto-fix mode upstream |
gherkin |
📋 report-only | Syntax validator, no auto-fix |
All --fix tools are bundled in the image — no host-side toolchain mounting
required. The previous "not bundled, detect-and-skip" behavior for
prettier / eslint / yamlfmt has been replaced by full coverage; they
only show as ⚠️ if the binary is somehow missing from a custom build.
File ownership when using
--fix: files patched inside the container are owned by whoever the container ran as. The README'sInstall as betterlint CLIsnippets pass--user "$(id -u):$(id -g)"so fixes land with the right owner on the host.
Two ways to call betterlint from anywhere on your host without typing the
full docker run … line every time. They are functionally identical —
betterlint plain → lint, betterlint --fix → auto-fix mode,
betterlint --help → forwarded to the in-image CLI, betterlint <file> →
file/flag arguments forwarded.
Minimal-invasive — no PATH mucking, no extra files on disk.
betterlint() {
docker run --rm \
-v "$PWD:/workspace" \
-w /workspace \
--user "$(id -u):$(id -g)" \
tcwlab/betterlint:latest "$@"
}Append the snippet to your shell rc file and source it (or open a new
shell). Pin to a specific tag in production by replacing :latest.
For cases where a shell function isn't an option (cron jobs, Makefiles,
non-interactive shells, multi-user systems). Drop
bin/betterlint-cli.sh into /usr/local/bin:
curl -fsSL https://raw.githubusercontent.com/tcwlab/betterlint/main/bin/betterlint-cli.sh \
-o /tmp/betterlint && \
chmod +x /tmp/betterlint && \
sudo mv /tmp/betterlint /usr/local/bin/betterlintOr, if you've cloned the repo:
sudo install -m 0755 bin/betterlint-cli.sh /usr/local/bin/betterlintOverride the image tag at runtime with BETTERLINT_IMAGE:
BETTERLINT_IMAGE=tcwlab/betterlint:1.0.0 betterlint --fixThe image is built to run under tight runtime restrictions so consumer pipelines can drop it into hardened environments without extra wrapping.
ENTRYPOINTlockdown. The image'sENTRYPOINTis fixed to/usr/local/bin/betterlint.docker run <image> /bin/shdoes not open a shell; the shell argument is forwarded to the wrapper as a CLI arg. Reaching a shell requires an explicit--entrypointoverride, which the CI gate verifies on every build.- Non-root by default — fixed UID/GID
10001:10001. Thebetterlintuser is created with a deterministic UID so host bind-mounts produce predictable file ownership across machines, and so Kubernetes / OpenShift admission controllers that enforcerunAsNonRootcan confirm the UID without resolving/etc/passwdinside the container. - SHA256-verified binary downloads.
hadolintandtflintare installed viacurlfrom upstream GitHub releases and then verified against pinned SHA256 sums in the Dockerfile (HADOLINT_SHA256_*,TFLINT_SHA256_*build args). A mismatch fails the build — no tampered or accidentally re-cut release artifact ever reaches the final image. - No healthcheck.
HEALTHCHECK NONEis set explicitly so no inherited or default healthcheck process keeps running in the background. The image is invoked one-shot. --read-only,--cap-drop=ALL, and--security-opt=no-new-privilegescompatible. The CI pipeline runs a hardened smoke-test on every build that exercises the image under all three flags simultaneously. If any flag would break the wrapper, the build fails before publish.
docker run --rm \
--read-only \
--tmpfs /tmp \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--user "$(id -u):$(id -g)" \
-v "$PWD:/workspace" \
tcwlab/betterlint:latest--user "$(id -u):$(id -g)" overrides the image's default UID so files written by --fix land with the host user's ownership. The image accepts any UID — the default 10001:10001 only kicks in when --user is not passed.
Drop-in replacement for the shell function in Install as betterlint CLI that always passes the security flags above:
betterlint() {
docker run --rm \
--read-only --tmpfs /tmp \
--cap-drop=ALL \
--security-opt=no-new-privileges \
--user "$(id -u):$(id -g)" \
-v "$PWD:/workspace" \
-w /workspace \
tcwlab/betterlint:latest "$@"
}Each published tag carries an SPDX SBOM and mode=max provenance attestation produced by docker buildx. Inspect via:
docker buildx imagetools inspect tcwlab/betterlint:<tag> \
--format '{{ json .SBOM }}'
docker buildx imagetools inspect tcwlab/betterlint:<tag> \
--format '{{ json .Provenance }}'Every build runs a two-pass Trivy scan against the image:
- Hard gate during
build-test:--severity HIGH,CRITICAL --ignore-unfixed --exit-code 1. Any HIGH or CRITICAL vulnerability with an upstream fix available fails the build immediately. Downstreamsecurityandpublishjobs do not run on a gated failure. - Reporting pass during
security: full scan including unfixable CVEs, posted as a PR comment so maintainers retain visibility. The most recent scan results are always available on the Docker Hub vulnerability tab.
This posture covers what betterlint ships in its image. Things explicitly out of scope:
- The lint tools themselves are upstream code. We pin versions, hash-verify binary downloads, and scan the image layers with Trivy, but we do not audit the source of
hadolint,tflint,markdownlint-cli2, or any other bundled tool. - Consumer Dockerfiles and configs are the consumer's responsibility. A repo that mounts secrets into
/workspacewill see those secrets inside the container — the image cannot defend against that. betterlintis not a security scanner. Application-level scanning istcwlab/trivy's job. Lint and scan are separate pipeline phases on purpose.
To report a security issue, open an issue marked security or contact the maintainers privately.
MegaLinter is excellent and supports far more languages, but it ships in a >1 GB image with linters that TCW projects do not use. betterlint keeps the image well below half a gigabyte by including only the linters and fixers that actually run in our pipelines, with deterministic auto-detect logic in a small Bash wrapper. Trade-off accepted: less coverage out of the box, faster pipelines and fewer moving parts.
If you need a linter that is not in this image, please open a feature request — once three or more consumers need the same tool, we evaluate adding it. Otherwise the recommendation is to run that linter in its own dedicated image.
- Source:
github.com/tcwlab/betterlint - Issues / feature requests:
github.com/tcwlab/betterlint/issues - Docker Hub:
hub.docker.com/r/tcwlab/betterlint
Every release is built and published by the repo's own .forgejo/workflows/ci.yml on a Forgejo runner:
- Multi-arch build (
linux/amd64,linux/arm64) viadocker buildxwith--sbom=true --provenance=mode=max. - Trivy vulnerability scan on
HIGH/CRITICALseverity (failures show up as PR comments). - Self-lint via
betterlintrunning against the just-built image.
The betterlint SemVer is cut by semantic-release from Conventional Commits on main.
Apache License 2.0. See LICENSE for the full text.