Sign inSign up

tcwlab/betterlint

By tcwlab

Updated 3 months ago

Image
0

2.3K

tcwlab/betterlint repository overview

tcwlab/betterlint

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, plus jq and yq) 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 Pulls Image Size License


Quick start

docker pull tcwlab/betterlint:latest

# Run against the current directory
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:latest

Or 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 :latest so you can try the image immediately. For production CI pipelines, pin a concrete tag — see Tags below.


Tags

Version numbers below are illustrative. For the current set of tags, see Docker Hub tags.

TagDescription
1.0.0, 1.0, 1Concrete SemVer (recommended for production pipelines)
latestRolling 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.


Supported architectures

  • linux/amd64
  • linux/arm64

Every tag is a multi-arch manifest list. Docker pulls the right architecture automatically.


What's included

ToolVersionPurpose
hadolint2.14.0Dockerfile linting
tflint0.62.0Terraform / OpenTofu linting
shellcheckfrom Alpine 3.23 apkShell script linting
shfmtfrom Alpine 3.23 apkShell script auto-formatter (used by --fix)
markdownlint-cli20.22.1Markdown linting (auto-fix supported)
@commitlint/cli20.xConventional Commits validation
@commitlint/config-conventional20.xDefault commitlint rule set
@stoplight/spectral-cli6.15.1OpenAPI / AsyncAPI linting
gherkin-official39.0.0Gherkin/BDD .feature syntax validation
prettier^3JS/TS/JSON/CSS/SCSS/HTML formatter (used by --fix)
eslint^9JS/TS linter + auto-fixer (used by --fix; ships flat-config default)
yamlfmt0.13.0YAML auto-formatter (used by --fix)
jqfrom Alpine apkJSON processor (validation + filter)
yqfrom Alpine apkYAML processor (validation + filter)
betterlint (wrapper)1.0.0Auto-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.


Usage

Run every relevant linter automatically
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:1.0.0

The 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.

Run only specific tools
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:1.0.0 \
  --only hadolint,shellcheck
Skip specific tools
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:1.0.0 \
  --skip commitlint,spectral
Generate a Markdown report (for PR comments)
docker run --rm -v "$PWD:/workspace" tcwlab/betterlint:1.0.0 \
  --markdown > lint-report.md

The image's ENTRYPOINT is locked to /usr/local/bin/betterlint, so every docker run invocation passes its arguments straight to the CLI. No need to retype betterlint between the image tag and the flag. (Old callers that still write … tcwlab/betterlint:1.0.0 betterlint --only foo keep working — the wrapper silently absorbs a leading betterlint arg for back-compat.)

Forgejo workflow — full snippet
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}"
CLI options
FlagDescription
--skip TOOL,...Skip these tools (comma-separated list)
--only TOOL,...Run only these tools (comma-separated list)
--dir PATHTarget directory (default: /workspace)
--fixRun auto-correctable fixers in-place, then lint as usual (details)
--markdownEmit a Markdown table (suitable for PR comments)
--versionPrint the wrapper version and exit
--helpPrint the full help text

Equivalent environment variables (overridden by CLI flags):

VariableEquivalent flag
BETTERLINT_SKIP--skip
BETTERLINT_ONLY--only
BETTERLINT_DIR--dir
BETTERLINT_FIX--fix (set to 1)
Selective linter invocation

The five canonical patterns:

ActionExample
Run a single linterbetterlint --only markdownlint
Run a list of lintersbetterlint --only markdownlint,shellcheck
Skip a single linterbetterlint --skip hadolint
Skip a list of lintersbetterlint --skip hadolint,tflint
Combine selection + auto-fixbetterlint --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 run

Configuration

Default configs baked into the image

If 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/:

ToolDefault configNotes
markdownlint/etc/betterlint/defaults/markdownlint.jsonMD013 and MD033 disabled
commitlint/etc/betterlint/defaults/commitlint.config.cjsConventional Commits 1.0, header max 100 chars
spectral/etc/betterlint/defaults/spectral.yamlActivates spectral:oas + spectral:asyncapi
eslint/etc/betterlint/defaults/eslint.config.jsFlat-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
Volume mount points
PathPurpose
/workspaceDefault 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's WORKDIR is /workspace so naked docker run lands there, but any custom mount

  • -w works transparently — -v "$PWD:/work" -w /work, -v "$PWD:/repo" -w /repo, etc. Use --dir PATH (or BETTERLINT_DIR) to override explicitly.
Detected file patterns
ToolPatterns
hadolintDockerfile, Dockerfile.*
tflint*.tf (per-directory)
shellcheck*.sh
markdownlint*.md
commitlintlatest git log -1 (only when the workdir is a git repo)
spectralopenapi*.yaml, openapi*.yml, asyncapi*.yaml, asyncapi*.yml
gherkin*.feature

Auto-fix mode

# 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:

  1. Fix phase — every fix-capable tool patches matching files in-place.
  2. 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).

Auto-fix support matrix
ToolMode under --fixNotes
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 formatBundled; *.yaml / *.yml
hadolint📋 report-onlyNo auto-fix mode upstream
tflint📋 report-onlyNo auto-fix mode upstream
shellcheck📋 report-onlyUse shfmt for formatting; shellcheck reports semantic issues
commitlint📋 report-onlyCommit messages can't be auto-fixed
spectral📋 report-onlyNo auto-fix mode upstream
gherkin📋 report-onlySyntax 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's Install as betterlint CLI snippets pass --user "$(id -u):$(id -g)" so fixes land with the right owner on the host.


Install as betterlint CLI

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.

Option 2: standalone wrapper script (system-wide)

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/betterlint

Or, if you've cloned the repo:

sudo install -m 0755 bin/betterlint-cli.sh /usr/local/bin/betterlint

Override the image tag at runtime with BETTERLINT_IMAGE:

BETTERLINT_IMAGE=tcwlab/betterlint:1.0.0 betterlint --fix

Security posture

The image is built to run under tight runtime restrictions so consumer pipelines can drop it into hardened environments without extra wrapping.

Hardening assumptions baked into the image
  • ENTRYPOINT lockdown. The image's ENTRYPOINT is fixed to /usr/local/bin/betterlint. docker run <image> /bin/sh does not open a shell; the shell argument is forwarded to the wrapper as a CLI arg. Reaching a shell requires an explicit --entrypoint override, which the CI gate verifies on every build.
  • Non-root by default — fixed UID/GID 10001:10001. The betterlint user is created with a deterministic UID so host bind-mounts produce predictable file ownership across machines, and so Kubernetes / OpenShift admission controllers that enforce runAsNonRoot can confirm the UID without resolving /etc/passwd inside the container.
  • SHA256-verified binary downloads. hadolint and tflint are installed via curl from 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 NONE is 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-privileges compatible. 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.

Hardened wrapper variant

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 "$@"
}
SBOM and provenance

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 }}'
Trivy gate in CI

Every build runs a two-pass Trivy scan against the image:

  1. 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. Downstream security and publish jobs do not run on a gated failure.
  2. 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.
Scope and non-goals

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 /workspace will see those secrets inside the container — the image cannot defend against that.
  • betterlint is not a security scanner. Application-level scanning is tcwlab/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.


Why betterlint and not MegaLinter?

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, issues, contributing


Build, supply chain

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) via docker buildx with --sbom=true --provenance=mode=max.
  • Trivy vulnerability scan on HIGH/CRITICAL severity (failures show up as PR comments).
  • Self-lint via betterlint running against the just-built image.

The betterlint SemVer is cut by semantic-release from Conventional Commits on main.


License

Apache License 2.0. See LICENSE for the full text.

Tag summary

Content type

Image

Digest

sha256:6c225a4a9

Size

142.5 MB

Last updated

3 months ago

docker pull tcwlab/betterlint